mirror of
https://github.com/Tony0410/nextstep.git
synced 2026-06-15 16:05:54 +08:00
Compare commits
4 Commits
cae436a20d
...
feature-up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb88288f4 | ||
|
|
f0f674945c | ||
|
|
065250c1cf | ||
|
|
a5181cf6fe |
@@ -33,6 +33,8 @@ RUN npm run build
|
|||||||
FROM node:20-slim AS runner
|
FROM node:20-slim AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y tzdata && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV TZ=Australia/Perth
|
ENV TZ=Australia/Perth
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ services:
|
|||||||
# Push notification VAPID keys
|
# Push notification VAPID keys
|
||||||
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=BEFs_VtoxY7SpNnd-ubz1ioliESlRI4sY6ny7Qp3rm7V1cm0gqyZX8TAHp4AaQ81yKC4LfWtJFQz_aHc25G-Tww
|
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=BEFs_VtoxY7SpNnd-ubz1ioliESlRI4sY6ny7Qp3rm7V1cm0gqyZX8TAHp4AaQ81yKC4LfWtJFQz_aHc25G-Tww
|
||||||
- VAPID_PRIVATE_KEY=KgVQVO7XhfCklrJ3o9wowzK90AxI6Exg9pXPq76Qx4A
|
- VAPID_PRIVATE_KEY=KgVQVO7XhfCklrJ3o9wowzK90AxI6Exg9pXPq76Qx4A
|
||||||
- VAPID_EMAIL=mailto:admin@nextstep.local
|
- VAPID_EMAIL=mailto:admin@example.com
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
998
docs/designs/2026-03-01-eight-features-design.md
Normal file
998
docs/designs/2026-03-01-eight-features-design.md
Normal file
@@ -0,0 +1,998 @@
|
|||||||
|
# Design: 8 New Features for Next Step
|
||||||
|
|
||||||
|
**Date:** 2026-03-01
|
||||||
|
**Priority:** Urgent
|
||||||
|
**Scope:** Medium (3-5 days)
|
||||||
|
**Target Users:** End users (patients & family caregivers)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Established Patterns (must follow)
|
||||||
|
|
||||||
|
All new features must follow these exact conventions from the codebase:
|
||||||
|
|
||||||
|
### File Patterns
|
||||||
|
- **Pages:** `src/app/(app)/<feature>/page.tsx` — `'use client'`, uses `useApp()` for workspace, `Header` + `PageContainer` layout
|
||||||
|
- **API routes:** `src/app/api/workspaces/[id]/<feature>/route.ts` — uses `withAuth`, `checkWorkspaceAccess`, `canEdit`, Zod validation, audit log on writes
|
||||||
|
- **Components:** `src/components/<feature>/ComponentName.tsx` — `'use client'`, uses UI kit (`Card`, `Button`, `showToast`)
|
||||||
|
- **Validation:** `src/lib/validation/schemas.ts` — Zod schemas with type exports
|
||||||
|
- **Dexie tables:** `src/lib/sync/db.ts` — interface + table definition, bump version
|
||||||
|
- **Sync ops:** `src/lib/sync/manager.ts` — add entity types and op handlers
|
||||||
|
|
||||||
|
### UI Patterns
|
||||||
|
- Colors: `primary-*` (sage green), `secondary-*` (warm stone), `accent-*` (terracotta), `alert-*` (soft red), `cream-*` (warm neutral)
|
||||||
|
- Semantic: `bg-background`, `bg-surface`, `bg-muted`, `border-border`
|
||||||
|
- Cards: `<Card>` with `shadow-card`, `rounded-card` (20px)
|
||||||
|
- Touch: `min-h-touch` (48px), large tap targets
|
||||||
|
- Typography: `font-display` for headings, `text-secondary-900` for titles, `text-secondary-500` for meta
|
||||||
|
- Icons: `lucide-react`, 6x6 default, stroke color matching text
|
||||||
|
- States: `LoadingState`, `EmptyState`, `ErrorState` from `@/components/ui`
|
||||||
|
- Toast: `showToast('message', 'success'|'error')`
|
||||||
|
- Page structure: `<Header title="X" />` then `<PageContainer className="pt-4 space-y-6">`
|
||||||
|
|
||||||
|
### API Patterns
|
||||||
|
```typescript
|
||||||
|
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
// ... logic
|
||||||
|
})
|
||||||
|
|
||||||
|
export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||||
|
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 = schema.safeParse(body)
|
||||||
|
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
|
||||||
|
// ... create + audit log
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data fetch pattern (pages)
|
||||||
|
```typescript
|
||||||
|
// 1. useLiveQuery from Dexie for offline-first
|
||||||
|
const localData = useLiveQuery(() => db.table.where('workspaceId').equals(id)..., [id])
|
||||||
|
// 2. Also fetch from server
|
||||||
|
const fetchData = useCallback(async () => { ... }, [currentWorkspace.id])
|
||||||
|
// 3. Combine: prefer server, fallback to local
|
||||||
|
const data = serverData.length > 0 ? serverData : localData || []
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prisma Schema Additions
|
||||||
|
|
||||||
|
All 8 features in a single migration:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
// ============================================
|
||||||
|
// TEMPERATURE LOG
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model TemperatureLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
workspaceId String
|
||||||
|
recordedAt DateTime @default(now())
|
||||||
|
tempCelsius Float
|
||||||
|
method String? // "oral", "forehead", "ear", "armpit"
|
||||||
|
notes String?
|
||||||
|
createdById String
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
version Int @default(1)
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User @relation("TempLogCreatedBy", fields: [createdById], references: [id])
|
||||||
|
|
||||||
|
@@index([workspaceId, recordedAt])
|
||||||
|
@@index([workspaceId, deletedAt])
|
||||||
|
@@index([syncedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONTACT DIRECTORY
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model Contact {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
workspaceId String
|
||||||
|
name String
|
||||||
|
role String // "Oncologist", "Pharmacist", etc.
|
||||||
|
category String // "ONCOLOGY", "HOSPITAL", "PHARMACY", "INSURANCE", "FAMILY", "OTHER"
|
||||||
|
phone String
|
||||||
|
phone2 String?
|
||||||
|
email String?
|
||||||
|
address String?
|
||||||
|
hours String? // "Mon-Fri 8am-5pm"
|
||||||
|
notes String?
|
||||||
|
isEmergency Boolean @default(false)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
deletedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdById String
|
||||||
|
updatedById String
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
version Int @default(1)
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User @relation("ContactCreatedBy", fields: [createdById], references: [id])
|
||||||
|
updatedBy User @relation("ContactUpdatedBy", fields: [updatedById], references: [id])
|
||||||
|
|
||||||
|
@@index([workspaceId, category])
|
||||||
|
@@index([workspaceId, deletedAt])
|
||||||
|
@@index([syncedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// WEIGHT LOG
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model WeightLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
workspaceId String
|
||||||
|
recordedAt DateTime @default(now())
|
||||||
|
weightKg Float
|
||||||
|
notes String?
|
||||||
|
createdById String
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
version Int @default(1)
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User @relation("WeightLogCreatedBy", fields: [createdById], references: [id])
|
||||||
|
|
||||||
|
@@index([workspaceId, recordedAt])
|
||||||
|
@@index([workspaceId, deletedAt])
|
||||||
|
@@index([syncedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TREATMENT TIMELINE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
enum MilestoneStatus {
|
||||||
|
SCHEDULED
|
||||||
|
COMPLETED
|
||||||
|
DELAYED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
model TreatmentMilestone {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
workspaceId String
|
||||||
|
type String // "CHEMO_CYCLE", "SURGERY", "RADIATION", "SCAN", "CONSULTATION", "OTHER"
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
plannedDate DateTime
|
||||||
|
actualDate DateTime?
|
||||||
|
status MilestoneStatus @default(SCHEDULED)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
notes String?
|
||||||
|
deletedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdById String
|
||||||
|
updatedById String
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
version Int @default(1)
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User @relation("MilestoneCreatedBy", fields: [createdById], references: [id])
|
||||||
|
updatedBy User @relation("MilestoneUpdatedBy", fields: [updatedById], references: [id])
|
||||||
|
|
||||||
|
@@index([workspaceId, plannedDate])
|
||||||
|
@@index([workspaceId, status])
|
||||||
|
@@index([workspaceId, deletedAt])
|
||||||
|
@@index([syncedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CAREGIVER TASKS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
enum TaskStatus {
|
||||||
|
TODO
|
||||||
|
IN_PROGRESS
|
||||||
|
DONE
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TaskPriority {
|
||||||
|
URGENT
|
||||||
|
HIGH
|
||||||
|
NORMAL
|
||||||
|
LOW
|
||||||
|
}
|
||||||
|
|
||||||
|
model CaregiverTask {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
workspaceId String
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
category String // "MEDICAL", "ERRANDS", "MEALS", "EMOTIONAL", "OTHER"
|
||||||
|
priority TaskPriority @default(NORMAL)
|
||||||
|
status TaskStatus @default(TODO)
|
||||||
|
assignedToId String?
|
||||||
|
dueDate DateTime?
|
||||||
|
completedAt DateTime?
|
||||||
|
completedById String?
|
||||||
|
deletedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdById String
|
||||||
|
updatedById String
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
version Int @default(1)
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User @relation("TaskCreatedBy", fields: [createdById], references: [id])
|
||||||
|
updatedBy User @relation("TaskUpdatedBy", fields: [updatedById], references: [id])
|
||||||
|
assignedTo User? @relation("TaskAssignedTo", fields: [assignedToId], references: [id])
|
||||||
|
completedBy User? @relation("TaskCompletedBy", fields: [completedById], references: [id])
|
||||||
|
|
||||||
|
@@index([workspaceId, status])
|
||||||
|
@@index([workspaceId, assignedToId])
|
||||||
|
@@index([workspaceId, dueDate])
|
||||||
|
@@index([workspaceId, deletedAt])
|
||||||
|
@@index([syncedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// LAB RESULTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model LabResult {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
workspaceId String
|
||||||
|
testDate DateTime
|
||||||
|
panelName String // "Complete Blood Count", "Comprehensive Metabolic", etc.
|
||||||
|
labName String? // "Quest", "Hospital Lab"
|
||||||
|
results Json // Array of { marker, value, unit, refMin, refMax, flag }
|
||||||
|
notes String?
|
||||||
|
deletedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdById String
|
||||||
|
updatedById String
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
version Int @default(1)
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User @relation("LabResultCreatedBy", fields: [createdById], references: [id])
|
||||||
|
updatedBy User @relation("LabResultUpdatedBy", fields: [updatedById], references: [id])
|
||||||
|
|
||||||
|
@@index([workspaceId, testDate])
|
||||||
|
@@index([workspaceId, deletedAt])
|
||||||
|
@@index([syncedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MEDICAL DOCUMENTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model MedicalDocument {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
workspaceId String
|
||||||
|
title String
|
||||||
|
category String // "LAB_REPORT", "SCAN", "INSURANCE", "ID_CARD", "PRESCRIPTION", "OTHER"
|
||||||
|
fileName String
|
||||||
|
fileSize Int // bytes
|
||||||
|
mimeType String // "application/pdf", "image/jpeg"
|
||||||
|
fileData Bytes // Store in DB as bytes (self-hosted, no S3)
|
||||||
|
dateTaken DateTime?
|
||||||
|
expiryDate DateTime?
|
||||||
|
notes String?
|
||||||
|
deletedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdById String
|
||||||
|
|
||||||
|
// Sync (no offline sync for file blobs — too large)
|
||||||
|
version Int @default(1)
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User @relation("DocCreatedBy", fields: [createdById], references: [id])
|
||||||
|
|
||||||
|
@@index([workspaceId, category])
|
||||||
|
@@index([workspaceId, deletedAt])
|
||||||
|
@@index([syncedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// DRUG INTERACTIONS (cached lookups)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model DrugInteraction {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
workspaceId String
|
||||||
|
medication1Id String
|
||||||
|
medication2Id String
|
||||||
|
severity String // "MINOR", "MODERATE", "MAJOR", "CONTRAINDICATED"
|
||||||
|
description String
|
||||||
|
checkedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
medication1 Medication @relation("Interaction1", fields: [medication1Id], references: [id], onDelete: Cascade)
|
||||||
|
medication2 Medication @relation("Interaction2", fields: [medication2Id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([workspaceId, medication1Id, medication2Id])
|
||||||
|
@@index([workspaceId])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### User model additions (relations)
|
||||||
|
```prisma
|
||||||
|
// Add to User model:
|
||||||
|
temperatureLogs TemperatureLog[] @relation("TempLogCreatedBy")
|
||||||
|
createdContacts Contact[] @relation("ContactCreatedBy")
|
||||||
|
updatedContacts Contact[] @relation("ContactUpdatedBy")
|
||||||
|
weightLogs WeightLog[] @relation("WeightLogCreatedBy")
|
||||||
|
createdMilestones TreatmentMilestone[] @relation("MilestoneCreatedBy")
|
||||||
|
updatedMilestones TreatmentMilestone[] @relation("MilestoneUpdatedBy")
|
||||||
|
createdTasks CaregiverTask[] @relation("TaskCreatedBy")
|
||||||
|
updatedTasks CaregiverTask[] @relation("TaskUpdatedBy")
|
||||||
|
assignedTasks CaregiverTask[] @relation("TaskAssignedTo")
|
||||||
|
completedTasks CaregiverTask[] @relation("TaskCompletedBy")
|
||||||
|
createdLabResults LabResult[] @relation("LabResultCreatedBy")
|
||||||
|
updatedLabResults LabResult[] @relation("LabResultUpdatedBy")
|
||||||
|
createdDocuments MedicalDocument[] @relation("DocCreatedBy")
|
||||||
|
|
||||||
|
// Add to Workspace model:
|
||||||
|
temperatureLogs TemperatureLog[]
|
||||||
|
contacts Contact[]
|
||||||
|
weightLogs WeightLog[]
|
||||||
|
milestones TreatmentMilestone[]
|
||||||
|
caregiverTasks CaregiverTask[]
|
||||||
|
labResults LabResult[]
|
||||||
|
medicalDocuments MedicalDocument[]
|
||||||
|
drugInteractions DrugInteraction[]
|
||||||
|
|
||||||
|
// Add to Medication model:
|
||||||
|
interactions1 DrugInteraction[] @relation("Interaction1")
|
||||||
|
interactions2 DrugInteraction[] @relation("Interaction2")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dexie DB (Version 3)
|
||||||
|
|
||||||
|
Add to `src/lib/sync/db.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// New interfaces
|
||||||
|
export interface LocalTemperatureLog {
|
||||||
|
id: string
|
||||||
|
workspaceId: string
|
||||||
|
recordedAt: string
|
||||||
|
tempCelsius: number
|
||||||
|
method: string | null
|
||||||
|
notes: string | null
|
||||||
|
deletedAt: string | null
|
||||||
|
version: number
|
||||||
|
syncedAt: string
|
||||||
|
createdBy?: { id: string; name: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalContact {
|
||||||
|
id: string
|
||||||
|
workspaceId: string
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
category: string
|
||||||
|
phone: string
|
||||||
|
phone2: string | null
|
||||||
|
email: string | null
|
||||||
|
address: string | null
|
||||||
|
hours: string | null
|
||||||
|
notes: string | null
|
||||||
|
isEmergency: boolean
|
||||||
|
sortOrder: number
|
||||||
|
deletedAt: string | null
|
||||||
|
version: number
|
||||||
|
syncedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalWeightLog {
|
||||||
|
id: string
|
||||||
|
workspaceId: string
|
||||||
|
recordedAt: string
|
||||||
|
weightKg: number
|
||||||
|
notes: string | null
|
||||||
|
deletedAt: string | null
|
||||||
|
version: number
|
||||||
|
syncedAt: string
|
||||||
|
createdBy?: { id: string; name: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalMilestone {
|
||||||
|
id: string
|
||||||
|
workspaceId: string
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
plannedDate: string
|
||||||
|
actualDate: string | null
|
||||||
|
status: string
|
||||||
|
sortOrder: number
|
||||||
|
notes: string | null
|
||||||
|
deletedAt: string | null
|
||||||
|
version: number
|
||||||
|
syncedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalCaregiverTask {
|
||||||
|
id: string
|
||||||
|
workspaceId: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
category: string
|
||||||
|
priority: string
|
||||||
|
status: string
|
||||||
|
assignedToId: string | null
|
||||||
|
dueDate: string | null
|
||||||
|
completedAt: string | null
|
||||||
|
deletedAt: string | null
|
||||||
|
version: number
|
||||||
|
syncedAt: string
|
||||||
|
assignedTo?: { id: string; name: string }
|
||||||
|
createdBy?: { id: string; name: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalLabResult {
|
||||||
|
id: string
|
||||||
|
workspaceId: string
|
||||||
|
testDate: string
|
||||||
|
panelName: string
|
||||||
|
labName: string | null
|
||||||
|
results: Array<{
|
||||||
|
marker: string
|
||||||
|
value: number
|
||||||
|
unit: string
|
||||||
|
refMin: number | null
|
||||||
|
refMax: number | null
|
||||||
|
flag: string | null // "LOW", "HIGH", "CRITICAL_LOW", "CRITICAL_HIGH", null
|
||||||
|
}>
|
||||||
|
notes: string | null
|
||||||
|
deletedAt: string | null
|
||||||
|
version: number
|
||||||
|
syncedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version 3 stores
|
||||||
|
this.version(3).stores({
|
||||||
|
appointments: 'id, workspaceId, datetime, deletedAt',
|
||||||
|
medications: 'id, workspaceId, active, deletedAt',
|
||||||
|
notes: 'id, workspaceId, type, deletedAt',
|
||||||
|
doseLogs: 'id, medicationId, workspaceId, takenAt',
|
||||||
|
workspaces: 'id',
|
||||||
|
symptoms: 'id, workspaceId, type, recordedAt, deletedAt',
|
||||||
|
temperatureLogs: 'id, workspaceId, recordedAt, deletedAt',
|
||||||
|
contacts: 'id, workspaceId, category, deletedAt',
|
||||||
|
weightLogs: 'id, workspaceId, recordedAt, deletedAt',
|
||||||
|
milestones: 'id, workspaceId, plannedDate, status, deletedAt',
|
||||||
|
caregiverTasks: 'id, workspaceId, status, assignedToId, deletedAt',
|
||||||
|
labResults: 'id, workspaceId, testDate, deletedAt',
|
||||||
|
outbox: 'id, workspaceId, timestamp',
|
||||||
|
syncMeta: 'id, workspaceId',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Medical documents are NOT stored in Dexie (too large for IndexedDB). They are server-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zod Validation Schemas
|
||||||
|
|
||||||
|
Add to `src/lib/validation/schemas.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Temperature Log
|
||||||
|
export const temperatureLogSchema = z.object({
|
||||||
|
tempCelsius: z.number().min(30).max(45),
|
||||||
|
method: z.enum(['oral', 'forehead', 'ear', 'armpit']).nullable().optional(),
|
||||||
|
notes: z.string().max(500).nullable().optional(),
|
||||||
|
recordedAt: z.string().datetime().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Contact
|
||||||
|
export const contactSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required').max(200),
|
||||||
|
role: z.string().min(1, 'Role is required').max(100),
|
||||||
|
category: z.enum(['ONCOLOGY', 'HOSPITAL', 'PHARMACY', 'INSURANCE', 'FAMILY', 'OTHER']),
|
||||||
|
phone: z.string().min(1, 'Phone is required').max(50),
|
||||||
|
phone2: z.string().max(50).nullable().optional(),
|
||||||
|
email: z.string().email().max(200).nullable().optional(),
|
||||||
|
address: z.string().max(500).nullable().optional(),
|
||||||
|
hours: z.string().max(200).nullable().optional(),
|
||||||
|
notes: z.string().max(1000).nullable().optional(),
|
||||||
|
isEmergency: z.boolean().default(false),
|
||||||
|
sortOrder: z.number().int().min(0).default(0),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Weight Log
|
||||||
|
export const weightLogSchema = z.object({
|
||||||
|
weightKg: z.number().min(1).max(500),
|
||||||
|
notes: z.string().max(500).nullable().optional(),
|
||||||
|
recordedAt: z.string().datetime().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Treatment Milestone
|
||||||
|
export const milestoneSchema = z.object({
|
||||||
|
type: z.enum(['CHEMO_CYCLE', 'SURGERY', 'RADIATION', 'SCAN', 'CONSULTATION', 'OTHER']),
|
||||||
|
title: z.string().min(1, 'Title is required').max(200),
|
||||||
|
description: z.string().max(1000).nullable().optional(),
|
||||||
|
plannedDate: z.string().datetime(),
|
||||||
|
actualDate: z.string().datetime().nullable().optional(),
|
||||||
|
status: z.enum(['SCHEDULED', 'COMPLETED', 'DELAYED', 'CANCELLED']).default('SCHEDULED'),
|
||||||
|
notes: z.string().max(2000).nullable().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Caregiver Task
|
||||||
|
export const caregiverTaskSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required').max(200),
|
||||||
|
description: z.string().max(2000).nullable().optional(),
|
||||||
|
category: z.enum(['MEDICAL', 'ERRANDS', 'MEALS', 'EMOTIONAL', 'OTHER']),
|
||||||
|
priority: z.enum(['URGENT', 'HIGH', 'NORMAL', 'LOW']).default('NORMAL'),
|
||||||
|
status: z.enum(['TODO', 'IN_PROGRESS', 'DONE', 'CANCELLED']).default('TODO'),
|
||||||
|
assignedToId: z.string().cuid().nullable().optional(),
|
||||||
|
dueDate: z.string().datetime().nullable().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Lab Result
|
||||||
|
const labMarkerSchema = z.object({
|
||||||
|
marker: z.string().min(1).max(50),
|
||||||
|
value: z.number(),
|
||||||
|
unit: z.string().max(20),
|
||||||
|
refMin: z.number().nullable().optional(),
|
||||||
|
refMax: z.number().nullable().optional(),
|
||||||
|
flag: z.enum(['LOW', 'HIGH', 'CRITICAL_LOW', 'CRITICAL_HIGH']).nullable().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const labResultSchema = z.object({
|
||||||
|
testDate: z.string().datetime(),
|
||||||
|
panelName: z.string().min(1).max(200),
|
||||||
|
labName: z.string().max(200).nullable().optional(),
|
||||||
|
results: z.array(labMarkerSchema).min(1),
|
||||||
|
notes: z.string().max(2000).nullable().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Medical Document (metadata only — file sent as multipart)
|
||||||
|
export const medicalDocumentSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required').max(200),
|
||||||
|
category: z.enum(['LAB_REPORT', 'SCAN', 'INSURANCE', 'ID_CARD', 'PRESCRIPTION', 'OTHER']),
|
||||||
|
dateTaken: z.string().datetime().nullable().optional(),
|
||||||
|
expiryDate: z.string().datetime().nullable().optional(),
|
||||||
|
notes: z.string().max(1000).nullable().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Drug Interaction Check
|
||||||
|
export const interactionCheckSchema = z.object({
|
||||||
|
medicationIds: z.array(z.string().cuid()).min(2).max(20),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Type exports
|
||||||
|
export type TemperatureLogInput = z.infer<typeof temperatureLogSchema>
|
||||||
|
export type ContactInput = z.infer<typeof contactSchema>
|
||||||
|
export type WeightLogInput = z.infer<typeof weightLogSchema>
|
||||||
|
export type MilestoneInput = z.infer<typeof milestoneSchema>
|
||||||
|
export type CaregiverTaskInput = z.infer<typeof caregiverTaskSchema>
|
||||||
|
export type LabMarker = z.infer<typeof labMarkerSchema>
|
||||||
|
export type LabResultInput = z.infer<typeof labResultSchema>
|
||||||
|
export type MedicalDocumentInput = z.infer<typeof medicalDocumentSchema>
|
||||||
|
export type InteractionCheckInput = z.infer<typeof interactionCheckSchema>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sync Ops Extensions
|
||||||
|
|
||||||
|
Add to `syncOpSchema.type`:
|
||||||
|
```
|
||||||
|
'LOG_TEMP', 'DELETE_TEMP',
|
||||||
|
'CREATE_CONTACT', 'UPDATE_CONTACT', 'DELETE_CONTACT',
|
||||||
|
'LOG_WEIGHT', 'DELETE_WEIGHT',
|
||||||
|
'CREATE_MILESTONE', 'UPDATE_MILESTONE', 'DELETE_MILESTONE',
|
||||||
|
'CREATE_TASK', 'UPDATE_TASK', 'DELETE_TASK', 'COMPLETE_TASK',
|
||||||
|
'CREATE_LAB', 'UPDATE_LAB', 'DELETE_LAB'
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `syncOpSchema.entityType`:
|
||||||
|
```
|
||||||
|
'TEMPERATURE_LOG', 'CONTACT', 'WEIGHT_LOG', 'MILESTONE', 'CAREGIVER_TASK', 'LAB_RESULT'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Feature 1: Temperature Log
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/workspaces/[id]/temperature` | List logs (query: from, to, limit) |
|
||||||
|
| POST | `/api/workspaces/[id]/temperature` | Create log |
|
||||||
|
| DELETE | `/api/workspaces/[id]/temperature/[tempId]` | Soft delete |
|
||||||
|
|
||||||
|
### Feature 2: Contact Directory
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/workspaces/[id]/contacts` | List contacts (query: category) |
|
||||||
|
| POST | `/api/workspaces/[id]/contacts` | Create contact |
|
||||||
|
| PATCH | `/api/workspaces/[id]/contacts/[contactId]` | Update contact |
|
||||||
|
| DELETE | `/api/workspaces/[id]/contacts/[contactId]` | Soft delete |
|
||||||
|
|
||||||
|
### Feature 3: Weight Log
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/workspaces/[id]/weight` | List logs (query: from, to, limit) |
|
||||||
|
| POST | `/api/workspaces/[id]/weight` | Create log |
|
||||||
|
| DELETE | `/api/workspaces/[id]/weight/[weightId]` | Soft delete |
|
||||||
|
|
||||||
|
### Feature 4: Treatment Timeline
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/workspaces/[id]/milestones` | List milestones |
|
||||||
|
| POST | `/api/workspaces/[id]/milestones` | Create milestone |
|
||||||
|
| PATCH | `/api/workspaces/[id]/milestones/[milestoneId]` | Update (inc. status) |
|
||||||
|
| DELETE | `/api/workspaces/[id]/milestones/[milestoneId]` | Soft delete |
|
||||||
|
|
||||||
|
### Feature 5: Caregiver Tasks
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/workspaces/[id]/tasks` | List tasks (query: status, assignedTo) |
|
||||||
|
| POST | `/api/workspaces/[id]/tasks` | Create task |
|
||||||
|
| PATCH | `/api/workspaces/[id]/tasks/[taskId]` | Update task |
|
||||||
|
| POST | `/api/workspaces/[id]/tasks/[taskId]/complete` | Mark complete |
|
||||||
|
| DELETE | `/api/workspaces/[id]/tasks/[taskId]` | Soft delete |
|
||||||
|
|
||||||
|
### Feature 6: Lab Results
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/workspaces/[id]/lab-results` | List results (query: from, to) |
|
||||||
|
| GET | `/api/workspaces/[id]/lab-results/trends` | Trend data for specific marker |
|
||||||
|
| POST | `/api/workspaces/[id]/lab-results` | Create result |
|
||||||
|
| PATCH | `/api/workspaces/[id]/lab-results/[labId]` | Update result |
|
||||||
|
| DELETE | `/api/workspaces/[id]/lab-results/[labId]` | Soft delete |
|
||||||
|
|
||||||
|
### Feature 7: Medical Documents
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/workspaces/[id]/documents` | List documents (metadata only) |
|
||||||
|
| POST | `/api/workspaces/[id]/documents` | Upload (multipart/form-data) |
|
||||||
|
| GET | `/api/workspaces/[id]/documents/[docId]` | Download file |
|
||||||
|
| DELETE | `/api/workspaces/[id]/documents/[docId]` | Soft delete |
|
||||||
|
|
||||||
|
### Feature 8: Drug Interactions
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/api/workspaces/[id]/medications/check-interactions` | Check all meds |
|
||||||
|
| GET | `/api/workspaces/[id]/medications/interactions` | Get cached results |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI/Page Design
|
||||||
|
|
||||||
|
### Navigation Change
|
||||||
|
|
||||||
|
The bottom nav currently has 5 items. With 8 new features, the "More" (Settings) tab becomes a hub. The new features are accessed via:
|
||||||
|
|
||||||
|
- **Today page** surfaces: Temperature, Weight (quick log cards), upcoming tasks, next milestone
|
||||||
|
- **Bottom nav** stays as-is (Today, Appts, Meds, Symptoms, More)
|
||||||
|
- **"More" page** (`/settings`) becomes a menu with sections:
|
||||||
|
- Account & Settings (existing)
|
||||||
|
- **Health Tracking**: Temperature, Weight, Lab Results
|
||||||
|
- **Care Team**: Contact Directory, Caregiver Tasks
|
||||||
|
- **Treatment**: Timeline, Medical Documents
|
||||||
|
- **Safety**: Drug Interactions
|
||||||
|
|
||||||
|
### Feature 1: Temperature Log — `/temperature`
|
||||||
|
|
||||||
|
**Page structure:**
|
||||||
|
- Header: "Temperature" with History icon (right)
|
||||||
|
- Quick log card: big number input (keyboard type=decimal), method selector (4 pill buttons: Oral/Forehead/Ear/Armpit), optional notes, "Log Temperature" button
|
||||||
|
- **Fever alert banner**: if last reading >= 38.0°C, show red alert card: "FEVER DETECTED — 38.3°C" with "Call Clinic" button using `tel:` link from workspace.clinicPhone
|
||||||
|
- Last 7 days: mini chart (horizontal bar or sparkline showing daily temps)
|
||||||
|
- Recent readings: list of TemperatureCard components
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- `src/components/temperature/TempQuickLog.tsx` — number input, method pills, submit
|
||||||
|
- `src/components/temperature/TempCard.tsx` — single reading display
|
||||||
|
- `src/components/temperature/TempChart.tsx` — 7-day chart (pure CSS bars, no lib needed)
|
||||||
|
- `src/components/temperature/FeverAlert.tsx` — red alert banner with call button
|
||||||
|
|
||||||
|
**Key UX:**
|
||||||
|
- Default unit based on locale (°C for AU). Display toggle °C/°F.
|
||||||
|
- 38.0°C threshold → yellow warning. 38.5°C → red emergency.
|
||||||
|
- Number input: show decimal keyboard on mobile via `inputMode="decimal"`
|
||||||
|
|
||||||
|
### Feature 2: Contact Directory — `/contacts`
|
||||||
|
|
||||||
|
**Page structure:**
|
||||||
|
- Header: "Care Team" with Plus icon (right)
|
||||||
|
- Category filter tabs: All / Oncology / Hospital / Pharmacy / Insurance / Family
|
||||||
|
- Emergency contacts section at top (if any marked isEmergency)
|
||||||
|
- Contact cards: avatar circle (first letter), name, role, big green CALL button
|
||||||
|
- Tap card → expand to show all details
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- `src/components/contacts/ContactCard.tsx` — name, role, call button, expandable
|
||||||
|
- `src/components/contacts/ContactForm.tsx` — modal form for create/edit
|
||||||
|
- `src/components/contacts/CategoryTabs.tsx` — horizontal scroll filter
|
||||||
|
|
||||||
|
### Feature 3: Weight Log — `/weight`
|
||||||
|
|
||||||
|
**Page structure:**
|
||||||
|
- Header: "Weight" with History icon
|
||||||
|
- Quick log: large number input (kg), small toggle for kg/lbs, notes, "Log Weight" button
|
||||||
|
- Trend card: 30-day line chart (CSS-based or simple SVG)
|
||||||
|
- Alert card: if weight changed >2kg in 24hrs, show warning
|
||||||
|
- Recent readings: list
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- `src/components/weight/WeightQuickLog.tsx`
|
||||||
|
- `src/components/weight/WeightCard.tsx`
|
||||||
|
- `src/components/weight/WeightChart.tsx` — simple SVG line chart
|
||||||
|
- `src/components/weight/WeightAlert.tsx` — rapid change warning
|
||||||
|
|
||||||
|
### Feature 4: Treatment Timeline — `/timeline`
|
||||||
|
|
||||||
|
**Page structure:**
|
||||||
|
- Header: "Treatment Journey" with Plus icon
|
||||||
|
- Progress bar at top: "Cycle 4 of 6 — 67% Complete" (calculated from completed/total milestones)
|
||||||
|
- Vertical timeline: milestones sorted by plannedDate
|
||||||
|
- Left: date
|
||||||
|
- Center: dot (green=completed, blue=scheduled, orange=delayed, gray=cancelled)
|
||||||
|
- Right: title, type badge, notes
|
||||||
|
- Bottom: "Add Milestone" button
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- `src/components/timeline/TimelineView.tsx` — vertical timeline layout
|
||||||
|
- `src/components/timeline/MilestoneCard.tsx` — single milestone
|
||||||
|
- `src/components/timeline/ProgressBar.tsx` — overall progress
|
||||||
|
- `src/components/timeline/MilestoneForm.tsx` — modal for create/edit
|
||||||
|
|
||||||
|
**Key UX:**
|
||||||
|
- Completed milestones have a subtle celebration effect (checkmark)
|
||||||
|
- Auto-scroll to "now" position in timeline
|
||||||
|
- Color by type: blue=chemo, orange=surgery, purple=radiation, green=scan
|
||||||
|
|
||||||
|
### Feature 5: Caregiver Tasks — `/tasks`
|
||||||
|
|
||||||
|
**Page structure:**
|
||||||
|
- Header: "Tasks" with Plus icon
|
||||||
|
- Filter tabs: My Tasks / All / Done
|
||||||
|
- Task list grouped by priority (Urgent at top)
|
||||||
|
- Each task: title, assignee avatar, due date, category chip, priority indicator
|
||||||
|
- Swipe right to complete (or tap checkbox)
|
||||||
|
- FAB or bottom "Add Task" button
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- `src/components/tasks/TaskCard.tsx` — task with checkbox, assignee, due date
|
||||||
|
- `src/components/tasks/TaskForm.tsx` — modal with assignee picker (workspace members)
|
||||||
|
- `src/components/tasks/TaskFilters.tsx` — status/assignee filter
|
||||||
|
|
||||||
|
**Key UX:**
|
||||||
|
- "Quick add" templates: "Pick up prescription", "Drive to appointment", "Prepare meals"
|
||||||
|
- Overdue tasks highlighted in accent/red
|
||||||
|
- Completion shows brief success animation
|
||||||
|
|
||||||
|
### Feature 6: Lab Results — `/lab-results`
|
||||||
|
|
||||||
|
**Page structure:**
|
||||||
|
- Header: "Lab Results" with Plus icon
|
||||||
|
- Tab: Recent / Trends
|
||||||
|
- **Recent tab:** List of lab result cards sorted by date, showing panel name, date, flag count
|
||||||
|
- **Trends tab:** Marker selector (dropdown: WBC, RBC, Platelets, Hemoglobin, etc.) → SVG line chart with reference range shaded
|
||||||
|
- Add result: modal with panel template selector (CBC template pre-fills common markers)
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- `src/components/labs/LabResultCard.tsx` — panel summary with flagged values highlighted
|
||||||
|
- `src/components/labs/LabResultForm.tsx` — panel selector + marker rows (marker/value/unit/range)
|
||||||
|
- `src/components/labs/LabTrendChart.tsx` — SVG chart with ref range shading
|
||||||
|
- `src/components/labs/MarkerRow.tsx` — single marker with flag coloring
|
||||||
|
|
||||||
|
**Key UX:**
|
||||||
|
- Pre-built panel templates: CBC (WBC, RBC, Hemoglobin, Hematocrit, Platelets), CMP, Liver, Tumor Markers
|
||||||
|
- Flag colors: green=normal, yellow=borderline, red=out of range, dark red=critical
|
||||||
|
- "Share with doctor" → links to print page
|
||||||
|
|
||||||
|
### Feature 7: Medical Documents — `/documents`
|
||||||
|
|
||||||
|
**Page structure:**
|
||||||
|
- Header: "Documents" with Plus icon (upload)
|
||||||
|
- Category filter: All / Lab Reports / Scans / Insurance / Prescriptions
|
||||||
|
- Document grid: 2 columns, thumbnail (icon by type), title, date, category badge
|
||||||
|
- Tap → full-screen viewer (PDF in iframe, images native)
|
||||||
|
- Upload: file picker, category select, title, date, notes
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- `src/components/documents/DocumentCard.tsx` — thumbnail, title, category badge
|
||||||
|
- `src/components/documents/DocumentUpload.tsx` — file picker modal
|
||||||
|
- `src/components/documents/DocumentViewer.tsx` — full-screen view
|
||||||
|
|
||||||
|
**Key UX:**
|
||||||
|
- Max file size: 10MB
|
||||||
|
- Accepted types: PDF, JPG, PNG
|
||||||
|
- Expiry badge on insurance cards approaching expiry
|
||||||
|
- No offline sync for documents (too large) — show "Requires internet" badge
|
||||||
|
|
||||||
|
### Feature 8: Drug Interaction Checker — `/meds` (integrated)
|
||||||
|
|
||||||
|
**Not a separate page.** Integrated into the medications section:
|
||||||
|
- "Check Interactions" button on meds list page
|
||||||
|
- Results shown as a modal/sheet with severity-colored cards
|
||||||
|
- Warning banner on individual medication detail pages if interactions exist
|
||||||
|
|
||||||
|
**Implementation approach (simplified, no external API for v1):**
|
||||||
|
- Ship with a local lookup table of ~200 common chemo drug interactions (JSON file)
|
||||||
|
- `src/lib/interactions/checker.ts` — pure function that takes med names, returns known interactions
|
||||||
|
- `src/lib/interactions/data.ts` — curated interaction database
|
||||||
|
- Can upgrade to external API (OpenFDA/RxNorm) later
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- `src/components/medications/InteractionCheck.tsx` — button + results modal
|
||||||
|
- `src/components/medications/InteractionCard.tsx` — severity badge, description
|
||||||
|
- `src/components/medications/InteractionBanner.tsx` — warning on med detail
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order & Tasks
|
||||||
|
|
||||||
|
### Batch 1: Schema & Infrastructure (do first)
|
||||||
|
|
||||||
|
- [ ] **Prisma schema migration** `priority:1` `time:30min`
|
||||||
|
- files: `prisma/schema.prisma`
|
||||||
|
- Add all 8 models + User/Workspace/Medication relation updates
|
||||||
|
- Run `npx prisma migrate dev --name add-eight-features`
|
||||||
|
- Verify migration succeeds
|
||||||
|
|
||||||
|
- [ ] **Zod validation schemas** `priority:1` `time:20min`
|
||||||
|
- files: `src/lib/validation/schemas.ts`
|
||||||
|
- Add all 8 schema definitions and type exports
|
||||||
|
|
||||||
|
- [ ] **Dexie DB version 3** `priority:1` `time:20min`
|
||||||
|
- files: `src/lib/sync/db.ts`
|
||||||
|
- Add interfaces and version 3 stores
|
||||||
|
|
||||||
|
- [ ] **Sync ops expansion** `priority:1` `time:15min`
|
||||||
|
- files: `src/lib/sync/manager.ts`, `src/lib/validation/schemas.ts`
|
||||||
|
- Add new entity types and op types to sync schema
|
||||||
|
|
||||||
|
### Batch 2: Low Complexity Features (build fast)
|
||||||
|
|
||||||
|
- [ ] **Feature 1: Temperature Log** `priority:2` `deps:Batch 1` `time:3hr`
|
||||||
|
- API: `src/app/api/workspaces/[id]/temperature/route.ts`
|
||||||
|
- API: `src/app/api/workspaces/[id]/temperature/[tempId]/route.ts`
|
||||||
|
- Components: `src/components/temperature/TempQuickLog.tsx`
|
||||||
|
- Components: `src/components/temperature/TempCard.tsx`
|
||||||
|
- Components: `src/components/temperature/TempChart.tsx`
|
||||||
|
- Components: `src/components/temperature/FeverAlert.tsx`
|
||||||
|
- Page: `src/app/(app)/temperature/page.tsx`
|
||||||
|
- Page: `src/app/(app)/temperature/history/page.tsx`
|
||||||
|
|
||||||
|
- [ ] **Feature 2: Contact Directory** `priority:2` `deps:Batch 1` `time:3hr`
|
||||||
|
- API: `src/app/api/workspaces/[id]/contacts/route.ts`
|
||||||
|
- API: `src/app/api/workspaces/[id]/contacts/[contactId]/route.ts`
|
||||||
|
- Components: `src/components/contacts/ContactCard.tsx`
|
||||||
|
- Components: `src/components/contacts/ContactForm.tsx`
|
||||||
|
- Components: `src/components/contacts/CategoryTabs.tsx`
|
||||||
|
- Page: `src/app/(app)/contacts/page.tsx`
|
||||||
|
|
||||||
|
- [ ] **Feature 3: Weight Log** `priority:2` `deps:Batch 1` `time:2.5hr`
|
||||||
|
- API: `src/app/api/workspaces/[id]/weight/route.ts`
|
||||||
|
- API: `src/app/api/workspaces/[id]/weight/[weightId]/route.ts`
|
||||||
|
- Components: `src/components/weight/WeightQuickLog.tsx`
|
||||||
|
- Components: `src/components/weight/WeightCard.tsx`
|
||||||
|
- Components: `src/components/weight/WeightChart.tsx`
|
||||||
|
- Components: `src/components/weight/WeightAlert.tsx`
|
||||||
|
- Page: `src/app/(app)/weight/page.tsx`
|
||||||
|
- Page: `src/app/(app)/weight/history/page.tsx`
|
||||||
|
|
||||||
|
### Batch 3: Medium Complexity Features
|
||||||
|
|
||||||
|
- [ ] **Feature 4: Treatment Timeline** `priority:3` `deps:Batch 1` `time:4hr`
|
||||||
|
- API: `src/app/api/workspaces/[id]/milestones/route.ts`
|
||||||
|
- API: `src/app/api/workspaces/[id]/milestones/[milestoneId]/route.ts`
|
||||||
|
- Components: `src/components/timeline/TimelineView.tsx`
|
||||||
|
- Components: `src/components/timeline/MilestoneCard.tsx`
|
||||||
|
- Components: `src/components/timeline/ProgressBar.tsx`
|
||||||
|
- Components: `src/components/timeline/MilestoneForm.tsx`
|
||||||
|
- Page: `src/app/(app)/timeline/page.tsx`
|
||||||
|
|
||||||
|
- [ ] **Feature 5: Caregiver Tasks** `priority:3` `deps:Batch 1` `time:4hr`
|
||||||
|
- API: `src/app/api/workspaces/[id]/tasks/route.ts`
|
||||||
|
- API: `src/app/api/workspaces/[id]/tasks/[taskId]/route.ts`
|
||||||
|
- API: `src/app/api/workspaces/[id]/tasks/[taskId]/complete/route.ts`
|
||||||
|
- Components: `src/components/tasks/TaskCard.tsx`
|
||||||
|
- Components: `src/components/tasks/TaskForm.tsx`
|
||||||
|
- Components: `src/components/tasks/TaskFilters.tsx`
|
||||||
|
- Page: `src/app/(app)/tasks/page.tsx`
|
||||||
|
|
||||||
|
### Batch 4: High Complexity Features
|
||||||
|
|
||||||
|
- [ ] **Feature 6: Lab Results** `priority:4` `deps:Batch 1` `time:5hr`
|
||||||
|
- Lib: `src/lib/labs/panels.ts` (CBC, CMP, Liver panel templates)
|
||||||
|
- API: `src/app/api/workspaces/[id]/lab-results/route.ts`
|
||||||
|
- API: `src/app/api/workspaces/[id]/lab-results/trends/route.ts`
|
||||||
|
- API: `src/app/api/workspaces/[id]/lab-results/[labId]/route.ts`
|
||||||
|
- Components: `src/components/labs/LabResultCard.tsx`
|
||||||
|
- Components: `src/components/labs/LabResultForm.tsx`
|
||||||
|
- Components: `src/components/labs/LabTrendChart.tsx`
|
||||||
|
- Components: `src/components/labs/MarkerRow.tsx`
|
||||||
|
- Page: `src/app/(app)/lab-results/page.tsx`
|
||||||
|
|
||||||
|
- [ ] **Feature 7: Medical Documents** `priority:4` `deps:Batch 1` `time:4hr`
|
||||||
|
- API: `src/app/api/workspaces/[id]/documents/route.ts` (multipart upload)
|
||||||
|
- API: `src/app/api/workspaces/[id]/documents/[docId]/route.ts` (download + delete)
|
||||||
|
- Components: `src/components/documents/DocumentCard.tsx`
|
||||||
|
- Components: `src/components/documents/DocumentUpload.tsx`
|
||||||
|
- Components: `src/components/documents/DocumentViewer.tsx`
|
||||||
|
- Page: `src/app/(app)/documents/page.tsx`
|
||||||
|
|
||||||
|
- [ ] **Feature 8: Drug Interactions** `priority:4` `deps:Batch 1` `time:3hr`
|
||||||
|
- Lib: `src/lib/interactions/data.ts` (curated interaction database)
|
||||||
|
- Lib: `src/lib/interactions/checker.ts` (lookup logic)
|
||||||
|
- API: `src/app/api/workspaces/[id]/medications/check-interactions/route.ts`
|
||||||
|
- Components: `src/components/medications/InteractionCheck.tsx`
|
||||||
|
- Components: `src/components/medications/InteractionCard.tsx`
|
||||||
|
- Components: `src/components/medications/InteractionBanner.tsx`
|
||||||
|
|
||||||
|
### Batch 5: Integration & Polish
|
||||||
|
|
||||||
|
- [ ] **Update Settings/More page** `priority:5` `time:1hr`
|
||||||
|
- files: `src/app/(app)/settings/page.tsx`
|
||||||
|
- Add navigation links to all new features grouped by section
|
||||||
|
|
||||||
|
- [ ] **Update Today dashboard** `priority:5` `time:2hr`
|
||||||
|
- files: `src/app/(app)/today/page.tsx`
|
||||||
|
- Add cards: latest temp, pending tasks, next milestone, weight trend
|
||||||
|
- Fever alert banner at top if applicable
|
||||||
|
|
||||||
|
- [ ] **Update EmptyState component** `priority:5` `time:15min`
|
||||||
|
- files: `src/components/ui/states.tsx`
|
||||||
|
- Add new icon types: temperature, contacts, weight, timeline, tasks, labs, documents
|
||||||
|
|
||||||
|
- [ ] **Tests** `priority:5` `time:2hr`
|
||||||
|
- Temperature threshold logic
|
||||||
|
- Weight change alert calculations
|
||||||
|
- Lab result flag detection
|
||||||
|
- Drug interaction checker
|
||||||
|
- All Zod schemas validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Total Estimated Time
|
||||||
|
|
||||||
|
| Batch | Time |
|
||||||
|
|-------|------|
|
||||||
|
| Batch 1: Schema & Infrastructure | ~1.5hr |
|
||||||
|
| Batch 2: Temperature + Contacts + Weight | ~8.5hr |
|
||||||
|
| Batch 3: Timeline + Tasks | ~8hr |
|
||||||
|
| Batch 4: Labs + Documents + Interactions | ~12hr |
|
||||||
|
| Batch 5: Integration & Polish | ~5hr |
|
||||||
|
| **Total** | **~35hr** |
|
||||||
|
|
||||||
|
With parallel work on independent features, achievable in 4-5 focused days.
|
||||||
@@ -36,6 +36,21 @@ model User {
|
|||||||
symptoms Symptom[]
|
symptoms Symptom[]
|
||||||
pushSubscriptions PushSubscription[]
|
pushSubscriptions PushSubscription[]
|
||||||
|
|
||||||
|
// New feature relations
|
||||||
|
temperatureLogs TemperatureLog[] @relation("TempLogCreatedBy")
|
||||||
|
createdContacts Contact[] @relation("ContactCreatedBy")
|
||||||
|
updatedContacts Contact[] @relation("ContactUpdatedBy")
|
||||||
|
weightLogs WeightLog[] @relation("WeightLogCreatedBy")
|
||||||
|
createdMilestones TreatmentMilestone[] @relation("MilestoneCreatedBy")
|
||||||
|
updatedMilestones TreatmentMilestone[] @relation("MilestoneUpdatedBy")
|
||||||
|
createdTasks CaregiverTask[] @relation("TaskCreatedBy")
|
||||||
|
updatedTasks CaregiverTask[] @relation("TaskUpdatedBy")
|
||||||
|
assignedTasks CaregiverTask[] @relation("TaskAssignedTo")
|
||||||
|
completedTasks CaregiverTask[] @relation("TaskCompletedBy")
|
||||||
|
createdLabResults LabResult[] @relation("LabResultCreatedBy")
|
||||||
|
updatedLabResults LabResult[] @relation("LabResultUpdatedBy")
|
||||||
|
createdDocuments MedicalDocument[] @relation("DocCreatedBy")
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +119,16 @@ model Workspace {
|
|||||||
appointmentChecklists AppointmentChecklist[]
|
appointmentChecklists AppointmentChecklist[]
|
||||||
pushSubscriptions PushSubscription[]
|
pushSubscriptions PushSubscription[]
|
||||||
|
|
||||||
|
// New feature relations
|
||||||
|
temperatureLogs TemperatureLog[]
|
||||||
|
contacts Contact[]
|
||||||
|
weightLogs WeightLog[]
|
||||||
|
milestones TreatmentMilestone[]
|
||||||
|
caregiverTasks CaregiverTask[]
|
||||||
|
labResults LabResult[]
|
||||||
|
medicalDocuments MedicalDocument[]
|
||||||
|
drugInteractions DrugInteraction[]
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +246,10 @@ model Medication {
|
|||||||
updatedBy User @relation("MedicationUpdatedBy", fields: [updatedById], references: [id])
|
updatedBy User @relation("MedicationUpdatedBy", fields: [updatedById], references: [id])
|
||||||
doseLogs DoseLog[]
|
doseLogs DoseLog[]
|
||||||
|
|
||||||
|
// Drug interaction relations
|
||||||
|
interactions1 DrugInteraction[] @relation("Interaction1")
|
||||||
|
interactions2 DrugInteraction[] @relation("Interaction2")
|
||||||
|
|
||||||
@@index([workspaceId, active])
|
@@index([workspaceId, active])
|
||||||
@@index([workspaceId, deletedAt])
|
@@index([workspaceId, deletedAt])
|
||||||
@@index([syncedAt])
|
@@index([syncedAt])
|
||||||
@@ -410,3 +439,279 @@ model SyncCursor {
|
|||||||
|
|
||||||
@@unique([workspaceId])
|
@@unique([workspaceId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TEMPERATURE LOG
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model TemperatureLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
workspaceId String
|
||||||
|
recordedAt DateTime @default(now())
|
||||||
|
tempCelsius Float
|
||||||
|
method String? // "oral", "forehead", "ear", "armpit"
|
||||||
|
notes String?
|
||||||
|
createdById String
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
version Int @default(1)
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User @relation("TempLogCreatedBy", fields: [createdById], references: [id])
|
||||||
|
|
||||||
|
@@index([workspaceId, recordedAt])
|
||||||
|
@@index([workspaceId, deletedAt])
|
||||||
|
@@index([syncedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONTACT DIRECTORY
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model Contact {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
workspaceId String
|
||||||
|
name String
|
||||||
|
role String // "Oncologist", "Pharmacist", etc.
|
||||||
|
category String // "ONCOLOGY", "HOSPITAL", "PHARMACY", "INSURANCE", "FAMILY", "OTHER"
|
||||||
|
phone String
|
||||||
|
phone2 String?
|
||||||
|
email String?
|
||||||
|
address String?
|
||||||
|
hours String? // "Mon-Fri 8am-5pm"
|
||||||
|
notes String?
|
||||||
|
isEmergency Boolean @default(false)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
deletedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdById String
|
||||||
|
updatedById String
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
version Int @default(1)
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User @relation("ContactCreatedBy", fields: [createdById], references: [id])
|
||||||
|
updatedBy User @relation("ContactUpdatedBy", fields: [updatedById], references: [id])
|
||||||
|
|
||||||
|
@@index([workspaceId, category])
|
||||||
|
@@index([workspaceId, deletedAt])
|
||||||
|
@@index([syncedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// WEIGHT LOG
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model WeightLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
workspaceId String
|
||||||
|
recordedAt DateTime @default(now())
|
||||||
|
weightKg Float
|
||||||
|
notes String?
|
||||||
|
createdById String
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
version Int @default(1)
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User @relation("WeightLogCreatedBy", fields: [createdById], references: [id])
|
||||||
|
|
||||||
|
@@index([workspaceId, recordedAt])
|
||||||
|
@@index([workspaceId, deletedAt])
|
||||||
|
@@index([syncedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TREATMENT TIMELINE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
enum MilestoneStatus {
|
||||||
|
SCHEDULED
|
||||||
|
COMPLETED
|
||||||
|
DELAYED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
model TreatmentMilestone {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
workspaceId String
|
||||||
|
type String // "CHEMO_CYCLE", "SURGERY", "RADIATION", "SCAN", "CONSULTATION", "OTHER"
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
plannedDate DateTime
|
||||||
|
actualDate DateTime?
|
||||||
|
status MilestoneStatus @default(SCHEDULED)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
notes String?
|
||||||
|
deletedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdById String
|
||||||
|
updatedById String
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
version Int @default(1)
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User @relation("MilestoneCreatedBy", fields: [createdById], references: [id])
|
||||||
|
updatedBy User @relation("MilestoneUpdatedBy", fields: [updatedById], references: [id])
|
||||||
|
|
||||||
|
@@index([workspaceId, plannedDate])
|
||||||
|
@@index([workspaceId, status])
|
||||||
|
@@index([workspaceId, deletedAt])
|
||||||
|
@@index([syncedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CAREGIVER TASKS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
enum TaskStatus {
|
||||||
|
TODO
|
||||||
|
IN_PROGRESS
|
||||||
|
DONE
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TaskPriority {
|
||||||
|
URGENT
|
||||||
|
HIGH
|
||||||
|
NORMAL
|
||||||
|
LOW
|
||||||
|
}
|
||||||
|
|
||||||
|
model CaregiverTask {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
workspaceId String
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
category String // "MEDICAL", "ERRANDS", "MEALS", "EMOTIONAL", "OTHER"
|
||||||
|
priority TaskPriority @default(NORMAL)
|
||||||
|
status TaskStatus @default(TODO)
|
||||||
|
assignedToId String?
|
||||||
|
dueDate DateTime?
|
||||||
|
completedAt DateTime?
|
||||||
|
completedById String?
|
||||||
|
deletedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdById String
|
||||||
|
updatedById String
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
version Int @default(1)
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User @relation("TaskCreatedBy", fields: [createdById], references: [id])
|
||||||
|
updatedBy User @relation("TaskUpdatedBy", fields: [updatedById], references: [id])
|
||||||
|
assignedTo User? @relation("TaskAssignedTo", fields: [assignedToId], references: [id])
|
||||||
|
completedBy User? @relation("TaskCompletedBy", fields: [completedById], references: [id])
|
||||||
|
|
||||||
|
@@index([workspaceId, status])
|
||||||
|
@@index([workspaceId, assignedToId])
|
||||||
|
@@index([workspaceId, dueDate])
|
||||||
|
@@index([workspaceId, deletedAt])
|
||||||
|
@@index([syncedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// LAB RESULTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model LabResult {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
workspaceId String
|
||||||
|
testDate DateTime
|
||||||
|
panelName String // "Complete Blood Count", "Comprehensive Metabolic", etc.
|
||||||
|
labName String? // "Quest", "Hospital Lab"
|
||||||
|
results Json // Array of { marker, value, unit, refMin, refMax, flag }
|
||||||
|
notes String?
|
||||||
|
deletedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdById String
|
||||||
|
updatedById String
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
version Int @default(1)
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User @relation("LabResultCreatedBy", fields: [createdById], references: [id])
|
||||||
|
updatedBy User @relation("LabResultUpdatedBy", fields: [updatedById], references: [id])
|
||||||
|
|
||||||
|
@@index([workspaceId, testDate])
|
||||||
|
@@index([workspaceId, deletedAt])
|
||||||
|
@@index([syncedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MEDICAL DOCUMENTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model MedicalDocument {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
workspaceId String
|
||||||
|
title String
|
||||||
|
category String // "LAB_REPORT", "SCAN", "INSURANCE", "ID_CARD", "PRESCRIPTION", "OTHER"
|
||||||
|
fileName String
|
||||||
|
fileSize Int // bytes
|
||||||
|
mimeType String // "application/pdf", "image/jpeg"
|
||||||
|
fileData Bytes // Store in DB as bytes (self-hosted, no S3)
|
||||||
|
dateTaken DateTime?
|
||||||
|
expiryDate DateTime?
|
||||||
|
notes String?
|
||||||
|
deletedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdById String
|
||||||
|
|
||||||
|
// Sync (no offline sync for file blobs — too large)
|
||||||
|
version Int @default(1)
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User @relation("DocCreatedBy", fields: [createdById], references: [id])
|
||||||
|
|
||||||
|
@@index([workspaceId, category])
|
||||||
|
@@index([workspaceId, deletedAt])
|
||||||
|
@@index([syncedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// DRUG INTERACTIONS (cached lookups)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model DrugInteraction {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
workspaceId String
|
||||||
|
medication1Id String
|
||||||
|
medication2Id String
|
||||||
|
severity String // "MINOR", "MODERATE", "MAJOR", "CONTRAINDICATED"
|
||||||
|
description String
|
||||||
|
checkedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
medication1 Medication @relation("Interaction1", fields: [medication1Id], references: [id], onDelete: Cascade)
|
||||||
|
medication2 Medication @relation("Interaction2", fields: [medication2Id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([workspaceId, medication1Id, medication2Id])
|
||||||
|
@@index([workspaceId])
|
||||||
|
}
|
||||||
|
|||||||
138
src/app/(app)/contacts/page.tsx
Normal file
138
src/app/(app)/contacts/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Plus, Users } from 'lucide-react'
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
|
|
||||||
|
import { db } from '@/lib/sync'
|
||||||
|
import { Card, LoadingState } from '@/components/ui'
|
||||||
|
import { Header, PageContainer } from '@/components/layout/header'
|
||||||
|
import { ContactCard } from '@/components/contacts/ContactCard'
|
||||||
|
import { ContactForm } from '@/components/contacts/ContactForm'
|
||||||
|
import { CategoryTabs } from '@/components/contacts/CategoryTabs'
|
||||||
|
import { useApp } from '../provider'
|
||||||
|
|
||||||
|
export default function ContactsPage() {
|
||||||
|
const { currentWorkspace, refreshData } = useApp()
|
||||||
|
const [serverContacts, setServerContacts] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editContact, setEditContact] = useState<any>(null)
|
||||||
|
const [category, setCategory] = useState('')
|
||||||
|
|
||||||
|
const localContacts = useLiveQuery(
|
||||||
|
() =>
|
||||||
|
db.contacts
|
||||||
|
.where('workspaceId')
|
||||||
|
.equals(currentWorkspace.id)
|
||||||
|
.and((c) => !c.deletedAt)
|
||||||
|
.toArray(),
|
||||||
|
[currentWorkspace.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchContacts = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const url = `/api/workspaces/${currentWorkspace.id}/contacts${category ? `?category=${category}` : ''}`
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setServerContacts(data.contacts)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch contacts:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [currentWorkspace.id, category])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchContacts()
|
||||||
|
}, [fetchContacts])
|
||||||
|
|
||||||
|
const handleSaved = () => {
|
||||||
|
fetchContacts()
|
||||||
|
refreshData()
|
||||||
|
setEditContact(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contacts = serverContacts.length > 0 ? serverContacts : localContacts || []
|
||||||
|
const filteredContacts = category
|
||||||
|
? contacts.filter((c: any) => c.category === category)
|
||||||
|
: contacts
|
||||||
|
|
||||||
|
// Separate emergency contacts
|
||||||
|
const emergencyContacts = filteredContacts.filter((c: any) => c.isEmergency)
|
||||||
|
const regularContacts = filteredContacts.filter((c: any) => !c.isEmergency)
|
||||||
|
|
||||||
|
if (loading && !localContacts) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Care Team" />
|
||||||
|
<PageContainer><LoadingState message="Loading contacts..." /></PageContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header
|
||||||
|
title="Care Team"
|
||||||
|
rightAction={{
|
||||||
|
icon: <Plus className="w-6 h-6 text-secondary-700" />,
|
||||||
|
label: 'Add',
|
||||||
|
onClick: () => setShowForm(true),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PageContainer className="pt-4 space-y-6">
|
||||||
|
{/* Category Filter */}
|
||||||
|
<CategoryTabs selected={category} onChange={setCategory} />
|
||||||
|
|
||||||
|
{/* Emergency Contacts */}
|
||||||
|
{emergencyContacts.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-red-600 uppercase tracking-wide mb-3">Emergency Contacts</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{emergencyContacts.map((contact: any) => (
|
||||||
|
<ContactCard
|
||||||
|
key={contact.id}
|
||||||
|
contact={contact}
|
||||||
|
onEdit={() => { setEditContact(contact); setShowForm(true) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All Contacts */}
|
||||||
|
<section>
|
||||||
|
{regularContacts.length === 0 && emergencyContacts.length === 0 ? (
|
||||||
|
<Card variant="outline" className="text-center py-8">
|
||||||
|
<Users className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
|
||||||
|
<p className="text-secondary-500">No contacts yet</p>
|
||||||
|
<p className="text-sm text-secondary-400 mt-1">Add your care team members and important contacts</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{regularContacts.map((contact: any) => (
|
||||||
|
<ContactCard
|
||||||
|
key={contact.id}
|
||||||
|
contact={contact}
|
||||||
|
onEdit={() => { setEditContact(contact); setShowForm(true) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</PageContainer>
|
||||||
|
|
||||||
|
{/* Contact Form Modal */}
|
||||||
|
<ContactForm
|
||||||
|
open={showForm}
|
||||||
|
onClose={() => { setShowForm(false); setEditContact(null) }}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
workspaceId={currentWorkspace.id}
|
||||||
|
initialData={editContact || undefined}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
171
src/app/(app)/documents/page.tsx
Normal file
171
src/app/(app)/documents/page.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Plus, FolderOpen } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Card, LoadingState, ConfirmModal, showToast } from '@/components/ui'
|
||||||
|
import { Header, PageContainer } from '@/components/layout/header'
|
||||||
|
import { DocumentCard } from '@/components/documents/DocumentCard'
|
||||||
|
import { DocumentUpload } from '@/components/documents/DocumentUpload'
|
||||||
|
import { DocumentViewer } from '@/components/documents/DocumentViewer'
|
||||||
|
import { useApp } from '../provider'
|
||||||
|
|
||||||
|
const CATEGORY_FILTERS = [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'LAB_REPORT', label: 'Lab Reports' },
|
||||||
|
{ value: 'SCAN', label: 'Scans' },
|
||||||
|
{ value: 'INSURANCE', label: 'Insurance' },
|
||||||
|
{ value: 'PRESCRIPTION', label: 'Prescriptions' },
|
||||||
|
{ value: 'OTHER', label: 'Other' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function DocumentsPage() {
|
||||||
|
const { currentWorkspace, refreshData } = useApp()
|
||||||
|
const [documents, setDocuments] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showUpload, setShowUpload] = useState(false)
|
||||||
|
const [viewDoc, setViewDoc] = useState<any>(null)
|
||||||
|
const [category, setCategory] = useState('')
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const fetchDocuments = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const url = `/api/workspaces/${currentWorkspace.id}/documents${category ? `?category=${category}` : ''}`
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setDocuments(data.documents)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch documents:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [currentWorkspace.id, category])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDocuments()
|
||||||
|
}, [fetchDocuments])
|
||||||
|
|
||||||
|
const handleSaved = () => {
|
||||||
|
fetchDocuments()
|
||||||
|
refreshData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteId) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/workspaces/${currentWorkspace.id}/documents/${deleteId}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
)
|
||||||
|
if (!response.ok) throw new Error('Failed to delete')
|
||||||
|
showToast('Document deleted', 'success')
|
||||||
|
setViewDoc(null)
|
||||||
|
setDeleteId(null)
|
||||||
|
fetchDocuments()
|
||||||
|
refreshData()
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to delete document', 'error')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Documents" />
|
||||||
|
<PageContainer><LoadingState message="Loading documents..." /></PageContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header
|
||||||
|
title="Documents"
|
||||||
|
rightAction={{
|
||||||
|
icon: <Plus className="w-6 h-6 text-secondary-700" />,
|
||||||
|
label: 'Upload',
|
||||||
|
onClick: () => setShowUpload(true),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PageContainer className="pt-4 space-y-6">
|
||||||
|
{/* Requires internet notice */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg px-3 py-2 text-xs text-blue-700">
|
||||||
|
Documents require an internet connection and are not available offline.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category filters */}
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide">
|
||||||
|
{CATEGORY_FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.value}
|
||||||
|
onClick={() => setCategory(f.value)}
|
||||||
|
className={`flex-shrink-0 px-4 py-2 rounded-full text-sm font-medium transition-all min-h-touch ${
|
||||||
|
category === f.value
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-secondary-100 text-secondary-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document list */}
|
||||||
|
{documents.length === 0 ? (
|
||||||
|
<Card variant="outline" className="text-center py-8">
|
||||||
|
<FolderOpen className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
|
||||||
|
<p className="text-secondary-500">No documents yet</p>
|
||||||
|
<p className="text-sm text-secondary-400 mt-1">
|
||||||
|
Upload lab reports, scans, insurance cards, and more
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{documents.map((doc: any) => (
|
||||||
|
<DocumentCard
|
||||||
|
key={doc.id}
|
||||||
|
document={doc}
|
||||||
|
onView={setViewDoc}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
|
||||||
|
{/* Upload Modal */}
|
||||||
|
<DocumentUpload
|
||||||
|
isOpen={showUpload}
|
||||||
|
onClose={() => setShowUpload(false)}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
workspaceId={currentWorkspace.id}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Document Viewer */}
|
||||||
|
<DocumentViewer
|
||||||
|
isOpen={!!viewDoc}
|
||||||
|
onClose={() => setViewDoc(null)}
|
||||||
|
onDelete={() => { setDeleteId(viewDoc?.id); }}
|
||||||
|
document={viewDoc}
|
||||||
|
workspaceId={currentWorkspace.id}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!deleteId}
|
||||||
|
onClose={() => setDeleteId(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Delete Document"
|
||||||
|
message="Are you sure you want to delete this document? This cannot be undone."
|
||||||
|
confirmText="Delete"
|
||||||
|
variant="danger"
|
||||||
|
loading={deleting}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { ArrowLeft, Edit2 } from 'lucide-react'
|
import { ArrowLeft, Edit2, Heart } from 'lucide-react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
|
|
||||||
import { db } from '@/lib/sync'
|
import { db } from '@/lib/sync'
|
||||||
import { EmergencyCard } from '@/components/emergency/EmergencyCard'
|
import { EmergencyCard } from '@/components/emergency/EmergencyCard'
|
||||||
import { Button, LoadingState } from '@/components/ui'
|
import { LoadingState } from '@/components/ui'
|
||||||
import { useApp } from '../provider'
|
import { useApp } from '../provider'
|
||||||
|
|
||||||
export default function EmergencyPage() {
|
export default function EmergencyPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { currentWorkspace } = useApp()
|
const { currentWorkspace } = useApp()
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Fetch workspace from IndexedDB for offline access
|
// Fetch workspace from IndexedDB for offline access
|
||||||
const workspace = useLiveQuery(
|
const workspace = useLiveQuery(
|
||||||
@@ -32,7 +37,11 @@ export default function EmergencyPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
return <LoadingState message="Loading emergency info..." />
|
return (
|
||||||
|
<div className="min-h-screen paper-texture flex items-center justify-center">
|
||||||
|
<LoadingState message="Loading emergency info..." />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const emergencyInfo = {
|
const emergencyInfo = {
|
||||||
@@ -56,58 +65,74 @@ export default function EmergencyPage() {
|
|||||||
})) || []
|
})) || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-red-50">
|
<div className={`min-h-screen paper-texture transition-opacity duration-500 ${mounted ? 'opacity-100' : 'opacity-0'}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-red-600 text-white safe-top">
|
<div className="bg-gradient-to-r from-alert-500 to-alert-600 text-white safe-area-top sticky top-0 z-10">
|
||||||
<div className="flex items-center justify-between px-4 py-3">
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="flex items-center gap-2 text-white/90 hover:text-white"
|
className="flex items-center gap-2 text-white/90 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-5 h-5" />
|
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center">
|
||||||
<span>Back</span>
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">Back</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{currentWorkspace.role !== 'VIEWER' && (
|
{currentWorkspace.role !== 'VIEWER' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/settings/emergency')}
|
onClick={() => router.push('/settings/emergency')}
|
||||||
className="flex items-center gap-2 text-white/90 hover:text-white"
|
className="flex items-center gap-2 text-white/90 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-4 h-4" />
|
<span className="font-medium">Edit</span>
|
||||||
<span>Edit</span>
|
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center">
|
||||||
|
<Edit2 className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4">
|
<div className="p-6 pb-24">
|
||||||
{hasInfo ? (
|
{hasInfo ? (
|
||||||
<EmergencyCard info={emergencyInfo} medications={medsList} />
|
<div className="animate-fade-up">
|
||||||
|
<EmergencyCard info={emergencyInfo} medications={medsList} />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<div className="section-warm text-center py-12 animate-fade-up">
|
||||||
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
<div className="w-20 h-20 rounded-full bg-alert-100 flex items-center justify-center mx-auto mb-6">
|
||||||
<Edit2 className="w-8 h-8 text-red-400" />
|
<Heart className="w-10 h-10 text-alert-400" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold text-secondary-900 mb-2">
|
|
||||||
|
<h2 className="font-display text-2xl text-secondary-900 mb-3">
|
||||||
No Emergency Info Set
|
No Emergency Info Set
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-secondary-600 mb-4">
|
|
||||||
Add important medical information for emergencies.
|
<p className="text-secondary-600 mb-8 max-w-sm mx-auto">
|
||||||
|
Add important medical information that could be crucial in an emergency situation.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{currentWorkspace.role !== 'VIEWER' && (
|
{currentWorkspace.role !== 'VIEWER' && (
|
||||||
<Button onClick={() => router.push('/settings/emergency')}>
|
<button
|
||||||
|
onClick={() => router.push('/settings/emergency')}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
Add Emergency Info
|
Add Emergency Info
|
||||||
</Button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Offline indicator */}
|
{/* Offline indicator */}
|
||||||
<div className="fixed bottom-4 left-4 right-4">
|
<div className="fixed bottom-6 left-6 right-6">
|
||||||
<div className="bg-green-100 border border-green-300 rounded-lg p-3 text-center">
|
<div className="bg-primary-50 border border-primary-200 rounded-card p-4 text-center shadow-elevated">
|
||||||
<p className="text-sm text-green-800 font-medium">
|
<div className="flex items-center justify-center gap-2">
|
||||||
This information is available offline
|
<div className="w-2 h-2 rounded-full bg-primary-500 animate-pulse" />
|
||||||
</p>
|
<p className="text-sm text-primary-700 font-medium">
|
||||||
|
This information is available offline
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
200
src/app/(app)/lab-results/page.tsx
Normal file
200
src/app/(app)/lab-results/page.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Plus, TestTubes } from 'lucide-react'
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
|
|
||||||
|
import { db } from '@/lib/sync'
|
||||||
|
import { Card, LoadingState, ConfirmModal, showToast, Select } from '@/components/ui'
|
||||||
|
import { Header, PageContainer } from '@/components/layout/header'
|
||||||
|
import { LabResultCard } from '@/components/labs/LabResultCard'
|
||||||
|
import { LabResultForm } from '@/components/labs/LabResultForm'
|
||||||
|
import { LabTrendChart } from '@/components/labs/LabTrendChart'
|
||||||
|
import { useApp } from '../provider'
|
||||||
|
|
||||||
|
const COMMON_MARKERS = [
|
||||||
|
'WBC', 'RBC', 'Hemoglobin', 'Hematocrit', 'Platelets',
|
||||||
|
'Neutrophils', 'Glucose', 'Creatinine', 'AST', 'ALT',
|
||||||
|
'CEA', 'CA 19-9',
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function LabResultsPage() {
|
||||||
|
const { currentWorkspace, refreshData } = useApp()
|
||||||
|
const [serverData, setServerData] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editResult, setEditResult] = useState<any>(null)
|
||||||
|
const [tab, setTab] = useState<'recent' | 'trends'>('recent')
|
||||||
|
const [trendMarker, setTrendMarker] = useState(COMMON_MARKERS[0])
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const localData = useLiveQuery(
|
||||||
|
() =>
|
||||||
|
db.labResults
|
||||||
|
.where('workspaceId')
|
||||||
|
.equals(currentWorkspace.id)
|
||||||
|
.and((r) => !r.deletedAt)
|
||||||
|
.reverse()
|
||||||
|
.toArray(),
|
||||||
|
[currentWorkspace.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/lab-results?limit=50`)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setServerData(data.labResults)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch lab results:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [currentWorkspace.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
const handleSaved = () => {
|
||||||
|
fetchData()
|
||||||
|
refreshData()
|
||||||
|
setEditResult(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (result: any) => {
|
||||||
|
setEditResult(result)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteId) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/workspaces/${currentWorkspace.id}/lab-results/${deleteId}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
)
|
||||||
|
if (!response.ok) throw new Error('Failed to delete')
|
||||||
|
showToast('Lab result deleted', 'success')
|
||||||
|
fetchData()
|
||||||
|
refreshData()
|
||||||
|
setDeleteId(null)
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to delete lab result', 'error')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = serverData.length > 0 ? serverData : localData || []
|
||||||
|
|
||||||
|
// Collect unique markers from results for the trend selector
|
||||||
|
const allMarkers = new Set<string>()
|
||||||
|
results.forEach((r: any) => {
|
||||||
|
(r.results || []).forEach((m: any) => allMarkers.add(m.marker))
|
||||||
|
})
|
||||||
|
const markerOptions = Array.from(allMarkers).map((m) => ({ value: m, label: m }))
|
||||||
|
if (markerOptions.length === 0) {
|
||||||
|
COMMON_MARKERS.forEach((m) => markerOptions.push({ value: m, label: m }))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && !localData) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Lab Results" />
|
||||||
|
<PageContainer><LoadingState message="Loading lab results..." /></PageContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header
|
||||||
|
title="Lab Results"
|
||||||
|
rightAction={{
|
||||||
|
icon: <Plus className="w-6 h-6 text-secondary-700" />,
|
||||||
|
label: 'Add',
|
||||||
|
onClick: () => { setEditResult(null); setShowForm(true) },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PageContainer className="pt-4 space-y-6">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['recent', 'trends'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={`flex-1 py-2.5 rounded-full text-sm font-medium transition-all min-h-touch ${
|
||||||
|
tab === t
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-secondary-100 text-secondary-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === 'recent' ? 'Recent' : 'Trends'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'recent' ? (
|
||||||
|
/* Recent results */
|
||||||
|
<section>
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<Card variant="outline" className="text-center py-8">
|
||||||
|
<TestTubes className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
|
||||||
|
<p className="text-secondary-500">No lab results yet</p>
|
||||||
|
<p className="text-sm text-secondary-400 mt-1">
|
||||||
|
Tap + to record your blood work results
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{results.map((result: any) => (
|
||||||
|
<LabResultCard key={result.id} result={result} onEdit={handleEdit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
/* Trends view */
|
||||||
|
<section className="space-y-4">
|
||||||
|
<Select
|
||||||
|
label="Select Marker"
|
||||||
|
value={trendMarker}
|
||||||
|
onChange={(e) => setTrendMarker(e.target.value)}
|
||||||
|
options={markerOptions}
|
||||||
|
/>
|
||||||
|
<Card>
|
||||||
|
<div className="p-4">
|
||||||
|
<LabTrendChart marker={trendMarker} workspaceId={currentWorkspace.id} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
|
||||||
|
{/* Form Modal */}
|
||||||
|
<LabResultForm
|
||||||
|
isOpen={showForm}
|
||||||
|
onClose={() => { setShowForm(false); setEditResult(null) }}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
workspaceId={currentWorkspace.id}
|
||||||
|
initialData={editResult || undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!deleteId}
|
||||||
|
onClose={() => setDeleteId(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Delete Lab Result"
|
||||||
|
message="Are you sure you want to delete this lab result?"
|
||||||
|
confirmText="Delete"
|
||||||
|
variant="danger"
|
||||||
|
loading={deleting}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import type { Medication, DoseLog, MedicationDueStatus } from '@/lib/schedule'
|
|||||||
import { Card, Button, LoadingState, EmptyState, showUndoToast, showToast } from '@/components/ui'
|
import { Card, Button, LoadingState, EmptyState, showUndoToast, showToast } from '@/components/ui'
|
||||||
import { Header, PageContainer } from '@/components/layout/header'
|
import { Header, PageContainer } from '@/components/layout/header'
|
||||||
import { RefillAlert } from '@/components/medications/RefillAlert'
|
import { RefillAlert } from '@/components/medications/RefillAlert'
|
||||||
|
import { InteractionCheck } from '@/components/medications/InteractionCheck'
|
||||||
import { useApp } from '../provider'
|
import { useApp } from '../provider'
|
||||||
|
|
||||||
export default function MedsPage() {
|
export default function MedsPage() {
|
||||||
@@ -140,6 +141,11 @@ export default function MedsPage() {
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Drug Interaction Checker */}
|
||||||
|
{medications.filter(m => m.active).length >= 2 && (
|
||||||
|
<InteractionCheck workspaceId={currentWorkspace.id} />
|
||||||
|
)}
|
||||||
|
|
||||||
{medications.length === 0 ? (
|
{medications.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
type="medications"
|
type="medications"
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
FileText,
|
FileText,
|
||||||
Bell,
|
Bell,
|
||||||
|
Thermometer,
|
||||||
|
Weight,
|
||||||
|
TestTubes,
|
||||||
|
FolderOpen,
|
||||||
|
ClipboardList,
|
||||||
|
Milestone,
|
||||||
|
Pill,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
|
|
||||||
@@ -281,6 +288,138 @@ export default function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Health Tracking */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||||
|
Health Tracking
|
||||||
|
</h2>
|
||||||
|
<Card padding="none">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/temperature')}
|
||||||
|
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Thermometer className="w-5 h-5 text-secondary-500" />
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="font-medium text-secondary-900">Temperature Log</p>
|
||||||
|
<p className="text-sm text-secondary-500">Track fever and temperature</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||||
|
</button>
|
||||||
|
<div className="border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/weight')}
|
||||||
|
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Weight className="w-5 h-5 text-secondary-500" />
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="font-medium text-secondary-900">Weight Tracking</p>
|
||||||
|
<p className="text-sm text-secondary-500">Monitor weight changes</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/lab-results')}
|
||||||
|
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<TestTubes className="w-5 h-5 text-secondary-500" />
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="font-medium text-secondary-900">Lab Results</p>
|
||||||
|
<p className="text-sm text-secondary-500">Blood work and test results</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Care Team */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||||
|
Care Team
|
||||||
|
</h2>
|
||||||
|
<Card padding="none">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/contacts')}
|
||||||
|
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">Contact Directory</p>
|
||||||
|
<p className="text-sm text-secondary-500">Doctors, nurses, pharmacists</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||||
|
</button>
|
||||||
|
<div className="border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/tasks')}
|
||||||
|
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<ClipboardList className="w-5 h-5 text-secondary-500" />
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="font-medium text-secondary-900">Caregiver Tasks</p>
|
||||||
|
<p className="text-sm text-secondary-500">Family task coordination</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Treatment */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||||
|
Treatment
|
||||||
|
</h2>
|
||||||
|
<Card padding="none">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/timeline')}
|
||||||
|
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Milestone className="w-5 h-5 text-secondary-500" />
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="font-medium text-secondary-900">Treatment Timeline</p>
|
||||||
|
<p className="text-sm text-secondary-500">Milestones and progress</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||||
|
</button>
|
||||||
|
<div className="border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/documents')}
|
||||||
|
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-5 h-5 text-secondary-500" />
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="font-medium text-secondary-900">Medical Documents</p>
|
||||||
|
<p className="text-sm text-secondary-500">Upload and store files</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Safety */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||||
|
Safety
|
||||||
|
</h2>
|
||||||
|
<Card padding="none">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/meds')}
|
||||||
|
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Pill className="w-5 h-5 text-secondary-500" />
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="font-medium text-secondary-900">Drug Interactions</p>
|
||||||
|
<p className="text-sm text-secondary-500">Check medication safety</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Family members */}
|
{/* Family members */}
|
||||||
{currentWorkspace.role === 'OWNER' && (
|
{currentWorkspace.role === 'OWNER' && (
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
292
src/app/(app)/tasks/page.tsx
Normal file
292
src/app/(app)/tasks/page.tsx
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Plus, ClipboardList } from 'lucide-react'
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
|
|
||||||
|
import { db } from '@/lib/sync'
|
||||||
|
import { Card, LoadingState, ConfirmModal, showToast } from '@/components/ui'
|
||||||
|
import { Header, PageContainer } from '@/components/layout/header'
|
||||||
|
import { TaskCard } from '@/components/tasks/TaskCard'
|
||||||
|
import { TaskForm } from '@/components/tasks/TaskForm'
|
||||||
|
import { TaskFilters } from '@/components/tasks/TaskFilters'
|
||||||
|
import { useApp } from '../provider'
|
||||||
|
|
||||||
|
export default function TasksPage() {
|
||||||
|
const { currentWorkspace, refreshData } = useApp()
|
||||||
|
const [serverTasks, setServerTasks] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editTask, setEditTask] = useState<any>(null)
|
||||||
|
const [filter, setFilter] = useState('all')
|
||||||
|
const [members, setMembers] = useState<Array<{ id: string; name: string }>>([])
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const localTasks = useLiveQuery(
|
||||||
|
() =>
|
||||||
|
db.caregiverTasks
|
||||||
|
.where('workspaceId')
|
||||||
|
.equals(currentWorkspace.id)
|
||||||
|
.and((t) => !t.deletedAt)
|
||||||
|
.toArray(),
|
||||||
|
[currentWorkspace.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchTasks = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/tasks?limit=200`)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setServerTasks(data.tasks)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch tasks:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [currentWorkspace.id])
|
||||||
|
|
||||||
|
const fetchMembers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/members`)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setMembers(data.members?.map((m: any) => ({ id: m.userId || m.id, name: m.user?.name || m.name })) || [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch members:', err)
|
||||||
|
}
|
||||||
|
}, [currentWorkspace.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTasks()
|
||||||
|
fetchMembers()
|
||||||
|
}, [fetchTasks, fetchMembers])
|
||||||
|
|
||||||
|
const handleSaved = () => {
|
||||||
|
fetchTasks()
|
||||||
|
refreshData()
|
||||||
|
setEditTask(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleComplete = async (taskId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/workspaces/${currentWorkspace.id}/tasks/${taskId}/complete`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
)
|
||||||
|
if (!response.ok) throw new Error('Failed to complete task')
|
||||||
|
showToast('Task completed!', 'success')
|
||||||
|
fetchTasks()
|
||||||
|
refreshData()
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to complete task', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteId) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/workspaces/${currentWorkspace.id}/tasks/${deleteId}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
)
|
||||||
|
if (!response.ok) throw new Error('Failed to delete task')
|
||||||
|
showToast('Task deleted', 'success')
|
||||||
|
fetchTasks()
|
||||||
|
refreshData()
|
||||||
|
setDeleteId(null)
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to delete task', 'error')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (task: any) => {
|
||||||
|
setEditTask(task)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTasks = serverTasks.length > 0 ? serverTasks : localTasks || []
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
const filteredTasks = allTasks.filter((t: any) => {
|
||||||
|
if (filter === 'done') return t.status === 'DONE'
|
||||||
|
if (filter === 'mine') return t.status !== 'DONE' && t.status !== 'CANCELLED'
|
||||||
|
// 'all' shows active tasks (not done/cancelled)
|
||||||
|
return t.status !== 'DONE' && t.status !== 'CANCELLED'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Group active tasks by priority
|
||||||
|
const urgentTasks = filteredTasks.filter((t: any) => t.priority === 'URGENT')
|
||||||
|
const highTasks = filteredTasks.filter((t: any) => t.priority === 'HIGH')
|
||||||
|
const normalTasks = filteredTasks.filter((t: any) => t.priority === 'NORMAL')
|
||||||
|
const lowTasks = filteredTasks.filter((t: any) => t.priority === 'LOW')
|
||||||
|
const doneTasks = filter === 'done' ? filteredTasks : []
|
||||||
|
|
||||||
|
const activeCount = allTasks.filter((t: any) => t.status !== 'DONE' && t.status !== 'CANCELLED').length
|
||||||
|
const doneCount = allTasks.filter((t: any) => t.status === 'DONE').length
|
||||||
|
|
||||||
|
if (loading && !localTasks) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Tasks" />
|
||||||
|
<PageContainer><LoadingState message="Loading tasks..." /></PageContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header
|
||||||
|
title="Tasks"
|
||||||
|
rightAction={{
|
||||||
|
icon: <Plus className="w-6 h-6 text-secondary-700" />,
|
||||||
|
label: 'Add',
|
||||||
|
onClick: () => { setEditTask(null); setShowForm(true) },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PageContainer className="pt-4 space-y-6">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Card className="flex-1 text-center py-3">
|
||||||
|
<p className="text-2xl font-bold text-primary-600">{activeCount}</p>
|
||||||
|
<p className="text-xs text-secondary-500">Active</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="flex-1 text-center py-3">
|
||||||
|
<p className="text-2xl font-bold text-green-600">{doneCount}</p>
|
||||||
|
<p className="text-xs text-secondary-500">Done</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<TaskFilters filter={filter} onFilterChange={setFilter} />
|
||||||
|
|
||||||
|
{/* Task Lists */}
|
||||||
|
{filter === 'done' ? (
|
||||||
|
<section>
|
||||||
|
{doneTasks.length === 0 ? (
|
||||||
|
<Card variant="outline" className="text-center py-8">
|
||||||
|
<p className="text-secondary-500">No completed tasks yet</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{doneTasks.map((task: any) => (
|
||||||
|
<TaskCard key={task.id} task={task} onEdit={handleEdit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{filteredTasks.length === 0 ? (
|
||||||
|
<Card variant="outline" className="text-center py-8">
|
||||||
|
<ClipboardList className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
|
||||||
|
<p className="text-secondary-500">No tasks yet</p>
|
||||||
|
<p className="text-sm text-secondary-400 mt-1">
|
||||||
|
Add tasks to coordinate care with your team
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{urgentTasks.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-red-600 uppercase tracking-wide mb-3">
|
||||||
|
Urgent
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{urgentTasks.map((task: any) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onComplete={handleComplete}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{highTasks.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-orange-600 uppercase tracking-wide mb-3">
|
||||||
|
High Priority
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{highTasks.map((task: any) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onComplete={handleComplete}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{normalTasks.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-secondary-600 uppercase tracking-wide mb-3">
|
||||||
|
Normal
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{normalTasks.map((task: any) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onComplete={handleComplete}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{lowTasks.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-secondary-400 uppercase tracking-wide mb-3">
|
||||||
|
Low Priority
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{lowTasks.map((task: any) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onComplete={handleComplete}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
|
||||||
|
{/* Task Form Modal */}
|
||||||
|
<TaskForm
|
||||||
|
isOpen={showForm}
|
||||||
|
onClose={() => { setShowForm(false); setEditTask(null) }}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
workspaceId={currentWorkspace.id}
|
||||||
|
members={members}
|
||||||
|
initialData={editTask || undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!deleteId}
|
||||||
|
onClose={() => setDeleteId(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Delete Task"
|
||||||
|
message="Are you sure you want to delete this task? This cannot be undone."
|
||||||
|
confirmText="Delete"
|
||||||
|
variant="danger"
|
||||||
|
loading={deleting}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
126
src/app/(app)/temperature/page.tsx
Normal file
126
src/app/(app)/temperature/page.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { History, Thermometer } from 'lucide-react'
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
|
|
||||||
|
import { db } from '@/lib/sync'
|
||||||
|
import { Card, LoadingState } from '@/components/ui'
|
||||||
|
import { Header, PageContainer } from '@/components/layout/header'
|
||||||
|
import { TempQuickLog } from '@/components/temperature/TempQuickLog'
|
||||||
|
import { TempCard } from '@/components/temperature/TempCard'
|
||||||
|
import { TempChart } from '@/components/temperature/TempChart'
|
||||||
|
import { FeverAlert } from '@/components/temperature/FeverAlert'
|
||||||
|
import { useApp } from '../provider'
|
||||||
|
|
||||||
|
export default function TemperaturePage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { currentWorkspace, refreshData } = useApp()
|
||||||
|
const [serverData, setServerData] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const localData = useLiveQuery(
|
||||||
|
() =>
|
||||||
|
db.temperatureLogs
|
||||||
|
.where('workspaceId')
|
||||||
|
.equals(currentWorkspace.id)
|
||||||
|
.and((t) => !t.deletedAt)
|
||||||
|
.reverse()
|
||||||
|
.limit(50)
|
||||||
|
.toArray(),
|
||||||
|
[currentWorkspace.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/temperature?limit=50`)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setServerData(data.temperatureLogs)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch temperature logs:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [currentWorkspace.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
const handleLogged = () => {
|
||||||
|
fetchData()
|
||||||
|
refreshData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const readings = serverData.length > 0 ? serverData : localData || []
|
||||||
|
const latestTemp = readings[0]?.tempCelsius ?? null
|
||||||
|
|
||||||
|
if (loading && !localData) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Temperature" />
|
||||||
|
<PageContainer><LoadingState message="Loading temperature logs..." /></PageContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header
|
||||||
|
title="Temperature"
|
||||||
|
rightAction={{
|
||||||
|
icon: <History className="w-6 h-6 text-secondary-700" />,
|
||||||
|
label: 'History',
|
||||||
|
onClick: () => router.push('/temperature/history'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PageContainer className="pt-4 space-y-6">
|
||||||
|
{/* Fever Alert */}
|
||||||
|
{latestTemp !== null && latestTemp >= 38.0 && (
|
||||||
|
<FeverAlert tempCelsius={latestTemp} clinicPhone={currentWorkspace.clinicPhone} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Log */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-secondary-900 mb-3">Log Temperature</h2>
|
||||||
|
<Card>
|
||||||
|
<TempQuickLog workspaceId={currentWorkspace.id} onLogged={handleLogged} />
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 7-Day Chart */}
|
||||||
|
{readings.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-secondary-900 mb-3">Last 7 Days</h2>
|
||||||
|
<Card>
|
||||||
|
<TempChart readings={readings.map((r: any) => ({ tempCelsius: r.tempCelsius, recordedAt: r.recordedAt }))} />
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Readings */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-lg font-semibold text-secondary-900">Recent</h2>
|
||||||
|
</div>
|
||||||
|
{readings.length === 0 ? (
|
||||||
|
<Card variant="outline" className="text-center py-8">
|
||||||
|
<Thermometer className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
|
||||||
|
<p className="text-secondary-500">No temperature readings yet</p>
|
||||||
|
<p className="text-sm text-secondary-400 mt-1">Use the form above to log your temperature</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{readings.slice(0, 5).map((reading: any) => (
|
||||||
|
<TempCard key={reading.id} reading={reading} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</PageContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
src/app/(app)/timeline/page.tsx
Normal file
145
src/app/(app)/timeline/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Plus, Milestone } from 'lucide-react'
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
|
|
||||||
|
import { db } from '@/lib/sync'
|
||||||
|
import { Card, LoadingState } from '@/components/ui'
|
||||||
|
import { Header, PageContainer } from '@/components/layout/header'
|
||||||
|
import { ProgressBar } from '@/components/timeline/ProgressBar'
|
||||||
|
import { TimelineView } from '@/components/timeline/TimelineView'
|
||||||
|
import { MilestoneForm } from '@/components/timeline/MilestoneForm'
|
||||||
|
import { useApp } from '../provider'
|
||||||
|
import { showToast } from '@/components/ui'
|
||||||
|
|
||||||
|
export default function TimelinePage() {
|
||||||
|
const { currentWorkspace, refreshData } = useApp()
|
||||||
|
const [serverData, setServerData] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editingMilestone, setEditingMilestone] = useState<any | null>(null)
|
||||||
|
|
||||||
|
const localData = useLiveQuery(
|
||||||
|
() =>
|
||||||
|
db.milestones
|
||||||
|
.where('workspaceId')
|
||||||
|
.equals(currentWorkspace.id)
|
||||||
|
.and((m) => !m.deletedAt)
|
||||||
|
.toArray(),
|
||||||
|
[currentWorkspace.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/milestones`)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setServerData(data.milestones)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch milestones:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [currentWorkspace.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
const handleSaved = () => {
|
||||||
|
fetchData()
|
||||||
|
refreshData()
|
||||||
|
setShowForm(false)
|
||||||
|
setEditingMilestone(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (milestone: any) => {
|
||||||
|
setEditingMilestone(milestone)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStatusChange = async (id: string, status: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/workspaces/${currentWorkspace.id}/milestones/${id}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!response.ok) throw new Error('Failed to update status')
|
||||||
|
showToast('Status updated', 'success')
|
||||||
|
fetchData()
|
||||||
|
refreshData()
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to update status', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const milestones = serverData.length > 0 ? serverData : localData || []
|
||||||
|
|
||||||
|
if (loading && !localData) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Treatment Journey" />
|
||||||
|
<PageContainer><LoadingState message="Loading milestones..." /></PageContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header
|
||||||
|
title="Treatment Journey"
|
||||||
|
rightAction={{
|
||||||
|
icon: <Plus className="w-6 h-6 text-secondary-700" />,
|
||||||
|
label: 'Add Milestone',
|
||||||
|
onClick: () => {
|
||||||
|
setEditingMilestone(null)
|
||||||
|
setShowForm(true)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PageContainer className="pt-4 space-y-6">
|
||||||
|
{/* Progress Bar */}
|
||||||
|
{milestones.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<ProgressBar milestones={milestones} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
{milestones.length === 0 ? (
|
||||||
|
<Card variant="outline" className="text-center py-8">
|
||||||
|
<Milestone className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
|
||||||
|
<p className="text-secondary-500">No milestones yet</p>
|
||||||
|
<p className="text-sm text-secondary-400 mt-1">Track your treatment journey by adding milestones</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<section>
|
||||||
|
<TimelineView
|
||||||
|
milestones={milestones}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onStatusChange={handleStatusChange}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
|
||||||
|
{/* Form Modal */}
|
||||||
|
<MilestoneForm
|
||||||
|
isOpen={showForm}
|
||||||
|
onClose={() => {
|
||||||
|
setShowForm(false)
|
||||||
|
setEditingMilestone(null)
|
||||||
|
}}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
workspaceId={currentWorkspace.id}
|
||||||
|
initialData={editingMilestone}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { useEffect, useState, useCallback } from 'react'
|
|||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { format, isToday, isTomorrow } from 'date-fns'
|
import { format, isToday, isTomorrow } from 'date-fns'
|
||||||
import { toZonedTime } from 'date-fns-tz'
|
import { toZonedTime } from 'date-fns-tz'
|
||||||
import { Phone, MapPin, Clock, ChevronRight, Pill, Calendar, Plus, AlertTriangle, ClipboardCheck } from 'lucide-react'
|
import { Phone, MapPin, Clock, ChevronRight, Pill, Calendar, Plus, AlertTriangle, ClipboardCheck, Heart, Thermometer, Weight, Milestone } from 'lucide-react'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
|
|
||||||
import { db, logDose, undoDose } from '@/lib/sync'
|
import { db, logDose, undoDose } from '@/lib/sync'
|
||||||
@@ -23,6 +23,11 @@ export default function TodayPage() {
|
|||||||
const [now, setNow] = useState(() => new Date())
|
const [now, setNow] = useState(() => new Date())
|
||||||
const [quickNote, setQuickNote] = useState('')
|
const [quickNote, setQuickNote] = useState('')
|
||||||
const [isAddingNote, setIsAddingNote] = useState(false)
|
const [isAddingNote, setIsAddingNote] = useState(false)
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Update time every minute
|
// Update time every minute
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,6 +65,43 @@ export default function TodayPage() {
|
|||||||
[currentWorkspace.id]
|
[currentWorkspace.id]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Latest temperature reading
|
||||||
|
const latestTemp = useLiveQuery(
|
||||||
|
() =>
|
||||||
|
db.temperatureLogs
|
||||||
|
.where('workspaceId')
|
||||||
|
.equals(currentWorkspace.id)
|
||||||
|
.and((t) => !t.deletedAt)
|
||||||
|
.reverse()
|
||||||
|
.sortBy('recordedAt')
|
||||||
|
.then((logs) => logs[0] ?? null),
|
||||||
|
[currentWorkspace.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Latest weight reading
|
||||||
|
const latestWeight = useLiveQuery(
|
||||||
|
() =>
|
||||||
|
db.weightLogs
|
||||||
|
.where('workspaceId')
|
||||||
|
.equals(currentWorkspace.id)
|
||||||
|
.and((w) => !w.deletedAt)
|
||||||
|
.reverse()
|
||||||
|
.sortBy('recordedAt')
|
||||||
|
.then((logs) => logs[0] ?? null),
|
||||||
|
[currentWorkspace.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pending caregiver tasks (due today or overdue)
|
||||||
|
const pendingTasks = useLiveQuery(
|
||||||
|
() =>
|
||||||
|
db.caregiverTasks
|
||||||
|
.where('workspaceId')
|
||||||
|
.equals(currentWorkspace.id)
|
||||||
|
.and((t) => !t.deletedAt && t.status !== 'DONE')
|
||||||
|
.toArray(),
|
||||||
|
[currentWorkspace.id]
|
||||||
|
)
|
||||||
|
|
||||||
// Calculate medication due statuses
|
// Calculate medication due statuses
|
||||||
const [medStatuses, setMedStatuses] = useState<MedicationDueStatus[]>([])
|
const [medStatuses, setMedStatuses] = useState<MedicationDueStatus[]>([])
|
||||||
|
|
||||||
@@ -149,7 +191,14 @@ export default function TodayPage() {
|
|||||||
const date = toZonedTime(new Date(datetime), TIMEZONE)
|
const date = toZonedTime(new Date(datetime), TIMEZONE)
|
||||||
if (isToday(date)) return `Today at ${format(date, 'h:mm a')}`
|
if (isToday(date)) return `Today at ${format(date, 'h:mm a')}`
|
||||||
if (isTomorrow(date)) return `Tomorrow 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')
|
return format(date, "EEE, MMM d 'at' h:mm a")
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGreeting = () => {
|
||||||
|
const hour = now.getHours()
|
||||||
|
if (hour < 12) return 'Good morning'
|
||||||
|
if (hour < 17) return 'Good afternoon'
|
||||||
|
return 'Good evening'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!appointments || !medications) {
|
if (!appointments || !medications) {
|
||||||
@@ -166,27 +215,41 @@ export default function TodayPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Today" />
|
<Header title="Today" />
|
||||||
<PageContainer className="pt-4 space-y-6">
|
<PageContainer className="pt-6 pb-24 space-y-8">
|
||||||
{/* Greeting */}
|
{/* Greeting Section with decorative elements */}
|
||||||
<div className="mb-2">
|
<div className={`relative transition-all duration-700 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||||
<p className="text-secondary-500 text-sm">
|
{/* Decorative blob */}
|
||||||
{format(toZonedTime(now, TIMEZONE), 'EEEE, MMMM d')}
|
<div className="blob blob-primary w-32 h-32 -top-4 -left-4" />
|
||||||
</p>
|
|
||||||
|
<div className="relative">
|
||||||
|
<p className="text-secondary-500 text-sm font-medium tracking-wide uppercase mb-1">
|
||||||
|
{format(toZonedTime(now, TIMEZONE), 'EEEE, MMMM d')}
|
||||||
|
</p>
|
||||||
|
<h1 className="font-display text-display-sm text-secondary-900">
|
||||||
|
{getGreeting()}
|
||||||
|
</h1>
|
||||||
|
<p className="text-secondary-600 mt-2 flex items-center gap-2">
|
||||||
|
<Heart className="w-4 h-4 text-accent-500" />
|
||||||
|
<span>Take it one step at a time</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Emergency & Call Clinic Buttons */}
|
{/* Emergency & Call Clinic Buttons - Floating cards */}
|
||||||
<div className="flex gap-3">
|
<div className={`flex gap-3 transition-all duration-700 delay-100 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||||
{/* Emergency Info Button */}
|
{/* Emergency Info Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/emergency')}
|
onClick={() => router.push('/emergency')}
|
||||||
className="flex items-center gap-3 p-4 bg-red-50 rounded-card border border-red-200 hover:bg-red-100 transition-colors flex-1"
|
className="flex-1 group relative overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-full bg-red-600 flex items-center justify-center">
|
<div className="relative flex items-center gap-3 p-4 bg-alert-50/80 backdrop-blur-sm rounded-card border border-alert-200/60 hover:border-alert-300 hover:shadow-elevated transition-all duration-300">
|
||||||
<AlertTriangle className="w-5 h-5 text-white" />
|
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-alert-500 to-alert-600 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform duration-300">
|
||||||
</div>
|
<AlertTriangle className="w-6 h-6 text-white" />
|
||||||
<div className="text-left">
|
</div>
|
||||||
<p className="font-medium text-red-800">Emergency</p>
|
<div className="text-left">
|
||||||
<p className="text-sm text-red-600">Medical info</p>
|
<p className="font-semibold text-alert-800">Emergency</p>
|
||||||
|
<p className="text-sm text-alert-600">Medical info</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -194,26 +257,28 @@ export default function TodayPage() {
|
|||||||
{currentWorkspace.clinicPhone && (
|
{currentWorkspace.clinicPhone && (
|
||||||
<a
|
<a
|
||||||
href={`tel:${currentWorkspace.clinicPhone}`}
|
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 flex-1"
|
className="flex-1 group"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-full bg-primary-500 flex items-center justify-center">
|
<div className="flex items-center gap-3 p-4 bg-primary-50/80 backdrop-blur-sm rounded-card border border-primary-200/60 hover:border-primary-300 hover:shadow-elevated transition-all duration-300">
|
||||||
<Phone className="w-5 h-5 text-white" />
|
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform duration-300">
|
||||||
</div>
|
<Phone className="w-6 h-6 text-white" />
|
||||||
<div className="text-left">
|
</div>
|
||||||
<p className="font-medium text-primary-800">Call Clinic</p>
|
<div className="text-left">
|
||||||
<p className="text-sm text-primary-600 truncate">{currentWorkspace.clinicPhone}</p>
|
<p className="font-semibold text-primary-800">Call Clinic</p>
|
||||||
|
<p className="text-sm text-primary-600 truncate max-w-[100px]">{currentWorkspace.clinicPhone}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Next Appointment */}
|
{/* Next Appointment - Hero Card */}
|
||||||
<section>
|
<section className={`transition-all duration-700 delay-200 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-secondary-900">Next Appointment</h2>
|
<h2 className="font-display text-xl text-secondary-900">Next Appointment</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/appointments')}
|
onClick={() => router.push('/appointments')}
|
||||||
className="text-sm text-primary-600 font-medium flex items-center"
|
className="text-sm text-primary-600 font-medium flex items-center gap-0.5 hover:text-primary-700 transition-colors"
|
||||||
>
|
>
|
||||||
View all
|
View all
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
@@ -221,30 +286,30 @@ export default function TodayPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{nextAppointment ? (
|
{nextAppointment ? (
|
||||||
<Card
|
<div
|
||||||
className="card-appointment"
|
className="card-appointment cursor-pointer group"
|
||||||
onClick={() => router.push(`/appointments/${nextAppointment.id}`)}
|
onClick={() => router.push(`/appointments/${nextAppointment.id}`)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center flex-shrink-0 shadow-inner">
|
||||||
<Calendar className="w-5 h-5 text-primary-600" />
|
<Calendar className="w-7 h-7 text-primary-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-secondary-900 truncate">
|
<h3 className="font-display text-lg text-secondary-900 truncate group-hover:text-primary-700 transition-colors">
|
||||||
{nextAppointment.title}
|
{nextAppointment.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-secondary-600 flex items-center gap-1 mt-1">
|
<p className="text-sm text-secondary-600 flex items-center gap-1.5 mt-1.5">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4 text-primary-500" />
|
||||||
{formatAppointmentDate(nextAppointment.datetime)}
|
{formatAppointmentDate(nextAppointment.datetime)}
|
||||||
</p>
|
</p>
|
||||||
{nextAppointment.location && (
|
{nextAppointment.location && (
|
||||||
<p className="text-sm text-secondary-500 flex items-center gap-1 mt-0.5">
|
<p className="text-sm text-secondary-500 flex items-center gap-1.5 mt-1">
|
||||||
<MapPin className="w-4 h-4" />
|
<MapPin className="w-4 h-4 text-cream-600" />
|
||||||
<span className="truncate">{nextAppointment.location}</span>
|
<span className="truncate">{nextAppointment.location}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="w-5 h-5 text-secondary-400" />
|
<ChevronRight className="w-5 h-5 text-secondary-300 group-hover:text-primary-500 group-hover:translate-x-1 transition-all" />
|
||||||
</div>
|
</div>
|
||||||
{nextAppointment.mapUrl && (
|
{nextAppointment.mapUrl && (
|
||||||
<a
|
<a
|
||||||
@@ -252,27 +317,27 @@ export default function TodayPage() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
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"
|
className="inline-flex items-center gap-1.5 mt-4 text-sm text-primary-600 font-medium hover:text-primary-700 hover:underline"
|
||||||
>
|
>
|
||||||
<MapPin className="w-4 h-4" />
|
<MapPin className="w-4 h-4" />
|
||||||
Open in Maps
|
Open in Maps
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card variant="outline" className="text-center py-6">
|
<div className="section-warm text-center py-8">
|
||||||
<Calendar className="w-8 h-8 text-secondary-300 mx-auto mb-2" />
|
<div className="w-16 h-16 rounded-full bg-cream-100 flex items-center justify-center mx-auto mb-4">
|
||||||
<p className="text-secondary-500">No upcoming appointments</p>
|
<Calendar className="w-8 h-8 text-cream-600" />
|
||||||
<Button
|
</div>
|
||||||
variant="ghost"
|
<p className="text-secondary-600 font-medium">No upcoming appointments</p>
|
||||||
size="sm"
|
<button
|
||||||
className="mt-2"
|
|
||||||
onClick={() => router.push('/appointments/new')}
|
onClick={() => router.push('/appointments/new')}
|
||||||
|
className="mt-4 inline-flex items-center gap-2 text-primary-600 font-medium hover:text-primary-700"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4" />
|
||||||
Add one
|
Add one
|
||||||
</Button>
|
</button>
|
||||||
</Card>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -283,26 +348,26 @@ export default function TodayPage() {
|
|||||||
)
|
)
|
||||||
if (tomorrowAppt) {
|
if (tomorrowAppt) {
|
||||||
return (
|
return (
|
||||||
<section>
|
<section className={`transition-all duration-700 delay-300 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||||
<Card
|
<div
|
||||||
className="bg-green-50 border border-green-200 cursor-pointer hover:bg-green-100 transition-colors"
|
className="bg-gradient-to-r from-cream-100 to-cream-50 border border-cream-200 rounded-card p-5 cursor-pointer hover:shadow-elevated transition-all duration-300 group"
|
||||||
onClick={() => router.push(`/appointments/${tomorrowAppt.id}/prep`)}
|
onClick={() => router.push(`/appointments/${tomorrowAppt.id}/prep`)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-10 h-10 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0">
|
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-accent-400 to-accent-500 flex items-center justify-center flex-shrink-0 shadow-lg group-hover:scale-105 transition-transform">
|
||||||
<ClipboardCheck className="w-5 h-5 text-white" />
|
<ClipboardCheck className="w-7 h-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-medium text-green-800">
|
<p className="font-display text-lg text-secondary-900">
|
||||||
Prepare for tomorrow
|
Prepare for tomorrow
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-green-600">
|
<p className="text-sm text-secondary-600">
|
||||||
{tomorrowAppt.title} at {format(toZonedTime(new Date(tomorrowAppt.datetime), TIMEZONE), 'h:mm a')}
|
{tomorrowAppt.title} at {format(toZonedTime(new Date(tomorrowAppt.datetime), TIMEZONE), 'h:mm a')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="w-5 h-5 text-green-500" />
|
<ChevronRight className="w-5 h-5 text-accent-500 group-hover:translate-x-1 transition-transform" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -311,23 +376,25 @@ export default function TodayPage() {
|
|||||||
|
|
||||||
{/* Refill Alerts */}
|
{/* Refill Alerts */}
|
||||||
{medications && medications.length > 0 && (
|
{medications && medications.length > 0 && (
|
||||||
<RefillAlert
|
<div className={`transition-all duration-700 delay-400 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||||
medications={medications.map(m => ({
|
<RefillAlert
|
||||||
id: m.id,
|
medications={medications.map(m => ({
|
||||||
name: m.name,
|
id: m.id,
|
||||||
pillCount: m.pillCount,
|
name: m.name,
|
||||||
refillThreshold: m.refillThreshold,
|
pillCount: m.pillCount,
|
||||||
}))}
|
refillThreshold: m.refillThreshold,
|
||||||
/>
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Meds Due */}
|
{/* Meds Due */}
|
||||||
<section>
|
<section className={`transition-all duration-700 delay-500 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-secondary-900">Medications</h2>
|
<h2 className="font-display text-xl text-secondary-900">Medications</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/meds')}
|
onClick={() => router.push('/meds')}
|
||||||
className="text-sm text-primary-600 font-medium flex items-center"
|
className="text-sm text-primary-600 font-medium flex items-center gap-0.5 hover:text-primary-700 transition-colors"
|
||||||
>
|
>
|
||||||
View all
|
View all
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
@@ -335,21 +402,25 @@ export default function TodayPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{medsDueSoon.length > 0 ? (
|
{medsDueSoon.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{medsDueSoon.map((status) => (
|
{medsDueSoon.map((status, index) => (
|
||||||
<MedicationCard
|
<MedicationCard
|
||||||
key={status.medication.id}
|
key={status.medication.id}
|
||||||
status={status}
|
status={status}
|
||||||
now={now}
|
now={now}
|
||||||
onTake={() => handleTakeMed(status)}
|
onTake={() => handleTakeMed(status)}
|
||||||
|
index={index}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : medications.length > 0 ? (
|
) : medications.length > 0 ? (
|
||||||
<Card variant="outline" className="text-center py-6">
|
<div className="section-warm text-center py-8">
|
||||||
<Pill className="w-8 h-8 text-secondary-300 mx-auto mb-2" />
|
<div className="w-16 h-16 rounded-full bg-primary-50 flex items-center justify-center mx-auto mb-4">
|
||||||
<p className="text-secondary-500">All caught up! No meds due soon.</p>
|
<Pill className="w-8 h-8 text-primary-400" />
|
||||||
</Card>
|
</div>
|
||||||
|
<p className="text-secondary-600 font-medium">All caught up!</p>
|
||||||
|
<p className="text-sm text-secondary-400 mt-1">No medications due soon</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
type="medications"
|
type="medications"
|
||||||
@@ -363,17 +434,91 @@ export default function TodayPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Health Snapshot Cards */}
|
||||||
|
<section className={`transition-all duration-700 delay-[600ms] ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||||
|
<h2 className="font-display text-xl text-secondary-900 mb-4">Health Snapshot</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{/* Temperature Card */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/temperature')}
|
||||||
|
className="bg-surface border border-border rounded-card p-4 text-left hover:shadow-elevated transition-all group"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mb-3 group-hover:scale-105 transition-transform">
|
||||||
|
<Thermometer className="w-5 h-5 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-secondary-500">Temperature</p>
|
||||||
|
{latestTemp ? (
|
||||||
|
<p className={`text-xl font-display mt-0.5 ${
|
||||||
|
latestTemp.tempCelsius >= 38.0 ? 'text-red-600' : 'text-secondary-900'
|
||||||
|
}`}>
|
||||||
|
{latestTemp.tempCelsius}°C
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-secondary-400 mt-0.5">No readings</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Weight Card */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/weight')}
|
||||||
|
className="bg-surface border border-border rounded-card p-4 text-left hover:shadow-elevated transition-all group"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center mb-3 group-hover:scale-105 transition-transform">
|
||||||
|
<Weight className="w-5 h-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-secondary-500">Weight</p>
|
||||||
|
{latestWeight ? (
|
||||||
|
<p className="text-xl font-display text-secondary-900 mt-0.5">
|
||||||
|
{latestWeight.weightKg} kg
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-secondary-400 mt-0.5">No readings</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Tasks Card */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/tasks')}
|
||||||
|
className="bg-surface border border-border rounded-card p-4 text-left hover:shadow-elevated transition-all group"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-accent-50 flex items-center justify-center mb-3 group-hover:scale-105 transition-transform">
|
||||||
|
<ClipboardCheck className="w-5 h-5 text-accent-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-secondary-500">Tasks</p>
|
||||||
|
{pendingTasks && pendingTasks.length > 0 ? (
|
||||||
|
<p className="text-xl font-display text-secondary-900 mt-0.5">
|
||||||
|
{pendingTasks.length} pending
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-secondary-400 mt-0.5">All done</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Timeline Card */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/timeline')}
|
||||||
|
className="bg-surface border border-border rounded-card p-4 text-left hover:shadow-elevated transition-all group"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary-50 flex items-center justify-center mb-3 group-hover:scale-105 transition-transform">
|
||||||
|
<Milestone className="w-5 h-5 text-primary-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-secondary-500">Timeline</p>
|
||||||
|
<p className="text-sm text-primary-600 font-medium mt-0.5">View progress</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Quick Note */}
|
{/* Quick Note */}
|
||||||
<section>
|
<section className={`transition-all duration-700 delay-[700ms] ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||||
<h2 className="text-lg font-semibold text-secondary-900 mb-3">Quick Note</h2>
|
<h2 className="font-display text-xl text-secondary-900 mb-4">Quick Note</h2>
|
||||||
<Card padding="sm">
|
<div className="section-warm">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={quickNote}
|
value={quickNote}
|
||||||
onChange={(e) => setQuickNote(e.target.value)}
|
onChange={(e) => setQuickNote(e.target.value)}
|
||||||
placeholder="Jot down a thought..."
|
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"
|
className="input-sanctuary flex-1"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && quickNote.trim()) {
|
if (e.key === 'Enter' && quickNote.trim()) {
|
||||||
handleAddQuickNote()
|
handleAddQuickNote()
|
||||||
@@ -384,11 +529,12 @@ export default function TodayPage() {
|
|||||||
onClick={handleAddQuickNote}
|
onClick={handleAddQuickNote}
|
||||||
disabled={!quickNote.trim() || isAddingNote}
|
disabled={!quickNote.trim() || isAddingNote}
|
||||||
loading={isAddingNote}
|
loading={isAddingNote}
|
||||||
|
className="btn-primary whitespace-nowrap"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
</>
|
</>
|
||||||
@@ -399,9 +545,10 @@ interface MedicationCardProps {
|
|||||||
status: MedicationDueStatus
|
status: MedicationDueStatus
|
||||||
now: Date
|
now: Date
|
||||||
onTake: () => void
|
onTake: () => void
|
||||||
|
index: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function MedicationCard({ status, now, onTake }: MedicationCardProps) {
|
function MedicationCard({ status, now, onTake, index }: MedicationCardProps) {
|
||||||
const { medication, isOverdue, isPRN, prnAvailable, prnAvailableAt, nextDueAt } = status
|
const { medication, isOverdue, isPRN, prnAvailable, prnAvailableAt, nextDueAt } = status
|
||||||
|
|
||||||
const getTimeLabel = () => {
|
const getTimeLabel = () => {
|
||||||
@@ -426,35 +573,38 @@ function MedicationCard({ status, now, onTake }: MedicationCardProps) {
|
|||||||
const canTake = !isPRN || prnAvailable
|
const canTake = !isPRN || prnAvailable
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={isOverdue ? 'overdue' : ''}>
|
<div className={`card-medication ${isOverdue ? 'overdue' : ''} animate-fade-up`} style={{ animationDelay: `${index * 0.1}s` }}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
<div className={`w-14 h-14 rounded-full flex items-center justify-center flex-shrink-0 shadow-inner transition-all duration-300 ${
|
||||||
<Pill className="w-5 h-5 text-primary-600" />
|
isOverdue
|
||||||
|
? 'bg-gradient-to-br from-accent-100 to-accent-200'
|
||||||
|
: 'bg-gradient-to-br from-primary-100 to-primary-200'
|
||||||
|
}`}>
|
||||||
|
<Pill className={`w-7 h-7 ${isOverdue ? 'text-accent-600' : 'text-primary-600'}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-secondary-900">{medication.name}</h3>
|
<h3 className="font-display text-lg text-secondary-900">{medication.name}</h3>
|
||||||
<p className={`text-sm ${isOverdue ? 'text-red-600 font-medium' : 'text-secondary-500'}`}>
|
<p className={`text-sm ${isOverdue ? 'text-accent-600 font-medium' : 'text-secondary-500'}`}>
|
||||||
{getTimeLabel()}
|
{getTimeLabel()}
|
||||||
{isPRN && ' • As needed'}
|
{isPRN && ' • As needed'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onTake()
|
onTake()
|
||||||
}}
|
}}
|
||||||
variant="success"
|
|
||||||
size="md"
|
|
||||||
disabled={!canTake}
|
disabled={!canTake}
|
||||||
|
className={`btn-primary text-sm px-5 py-2.5 ${!canTake ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
Taken
|
Taken
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{medication.instructions && (
|
{medication.instructions && (
|
||||||
<p className="text-sm text-secondary-500 mt-2 ml-13">
|
<p className="text-sm text-secondary-500 mt-3 ml-[72px]">
|
||||||
{medication.instructions}
|
{medication.instructions}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
148
src/app/(app)/weight/page.tsx
Normal file
148
src/app/(app)/weight/page.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { History, Scale } from 'lucide-react'
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
|
|
||||||
|
import { db } from '@/lib/sync'
|
||||||
|
import { Card, LoadingState } from '@/components/ui'
|
||||||
|
import { Header, PageContainer } from '@/components/layout/header'
|
||||||
|
import { WeightQuickLog } from '@/components/weight/WeightQuickLog'
|
||||||
|
import { WeightCard } from '@/components/weight/WeightCard'
|
||||||
|
import { WeightChart } from '@/components/weight/WeightChart'
|
||||||
|
import { WeightAlert } from '@/components/weight/WeightAlert'
|
||||||
|
import { useApp } from '../provider'
|
||||||
|
|
||||||
|
export default function WeightPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { currentWorkspace, refreshData } = useApp()
|
||||||
|
const [serverData, setServerData] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const localData = useLiveQuery(
|
||||||
|
() =>
|
||||||
|
db.weightLogs
|
||||||
|
.where('workspaceId')
|
||||||
|
.equals(currentWorkspace.id)
|
||||||
|
.and((w) => !w.deletedAt)
|
||||||
|
.reverse()
|
||||||
|
.limit(100)
|
||||||
|
.toArray(),
|
||||||
|
[currentWorkspace.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/weight?limit=100`)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setServerData(data.weightLogs)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch weight logs:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [currentWorkspace.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
const handleLogged = () => {
|
||||||
|
fetchData()
|
||||||
|
refreshData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const readings = useMemo(
|
||||||
|
() => (serverData.length > 0 ? serverData : localData || []),
|
||||||
|
[serverData, localData]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check for rapid weight change
|
||||||
|
const rapidChange = useMemo(() => {
|
||||||
|
if (readings.length < 2) return null
|
||||||
|
const latest = readings[0]
|
||||||
|
const previous = readings[1]
|
||||||
|
const hoursDiff = (new Date(latest.recordedAt).getTime() - new Date(previous.recordedAt).getTime()) / (1000 * 60 * 60)
|
||||||
|
if (hoursDiff <= 48 && Math.abs(latest.weightKg - previous.weightKg) >= 2) {
|
||||||
|
return { currentKg: latest.weightKg, previousKg: previous.weightKg, timeframeHours: Math.round(hoursDiff) }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [readings])
|
||||||
|
|
||||||
|
if (loading && !localData) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Weight" />
|
||||||
|
<PageContainer><LoadingState message="Loading weight logs..." /></PageContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header
|
||||||
|
title="Weight"
|
||||||
|
rightAction={{
|
||||||
|
icon: <History className="w-6 h-6 text-secondary-700" />,
|
||||||
|
label: 'History',
|
||||||
|
onClick: () => router.push('/weight/history'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PageContainer className="pt-4 space-y-6">
|
||||||
|
{/* Rapid Change Alert */}
|
||||||
|
{rapidChange && (
|
||||||
|
<WeightAlert
|
||||||
|
currentKg={rapidChange.currentKg}
|
||||||
|
previousKg={rapidChange.previousKg}
|
||||||
|
timeframeHours={rapidChange.timeframeHours}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Log */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-secondary-900 mb-3">Log Weight</h2>
|
||||||
|
<Card>
|
||||||
|
<WeightQuickLog workspaceId={currentWorkspace.id} onLogged={handleLogged} />
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 30-Day Trend */}
|
||||||
|
{readings.length >= 2 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-secondary-900 mb-3">30-Day Trend</h2>
|
||||||
|
<Card>
|
||||||
|
<WeightChart readings={readings.map((r: any) => ({ weightKg: r.weightKg, recordedAt: r.recordedAt }))} />
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Readings */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-lg font-semibold text-secondary-900">Recent</h2>
|
||||||
|
</div>
|
||||||
|
{readings.length === 0 ? (
|
||||||
|
<Card variant="outline" className="text-center py-8">
|
||||||
|
<Scale className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
|
||||||
|
<p className="text-secondary-500">No weight readings yet</p>
|
||||||
|
<p className="text-sm text-secondary-400 mt-1">Use the form above to track your weight</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{readings.slice(0, 5).map((reading: any, i: number) => (
|
||||||
|
<WeightCard
|
||||||
|
key={reading.id}
|
||||||
|
reading={reading}
|
||||||
|
previousKg={i < readings.length - 1 ? readings[i + 1]?.weightKg : null}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</PageContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -78,7 +78,11 @@ async function handler(req: NextRequest) {
|
|||||||
// Create session
|
// Create session
|
||||||
const userAgent = req.headers.get('user-agent') || undefined
|
const userAgent = req.headers.get('user-agent') || undefined
|
||||||
const token = await createSession(user.id, userAgent, ipAddress)
|
const token = await createSession(user.id, userAgent, ipAddress)
|
||||||
const cookieConfig = getSessionCookieConfig(token)
|
const cookieConfig = getSessionCookieConfig(token, {
|
||||||
|
forwardedProto: req.headers.get('x-forwarded-proto'),
|
||||||
|
origin: req.headers.get('origin'),
|
||||||
|
referer: req.headers.get('referer'),
|
||||||
|
})
|
||||||
|
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getSession, deleteSession, getSessionCookieClearConfig } from '@/lib/auth'
|
import { getSession, deleteSession, getSessionCookieClearConfig } from '@/lib/auth'
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
|
|
||||||
@@ -9,7 +9,11 @@ export async function POST() {
|
|||||||
await deleteSession(session.sessionId)
|
await deleteSession(session.sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookieConfig = getSessionCookieClearConfig()
|
const cookieConfig = getSessionCookieClearConfig({
|
||||||
|
forwardedProto: req.headers.get('x-forwarded-proto'),
|
||||||
|
origin: req.headers.get('origin'),
|
||||||
|
referer: req.headers.get('referer'),
|
||||||
|
})
|
||||||
const response = NextResponse.json({ message: 'Logged out successfully' })
|
const response = NextResponse.json({ message: 'Logged out successfully' })
|
||||||
response.cookies.set(cookieConfig)
|
response.cookies.set(cookieConfig)
|
||||||
|
|
||||||
@@ -17,7 +21,11 @@ export async function POST() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout error:', error)
|
console.error('Logout error:', error)
|
||||||
// Still clear the cookie even on error
|
// Still clear the cookie even on error
|
||||||
const cookieConfig = getSessionCookieClearConfig()
|
const cookieConfig = getSessionCookieClearConfig({
|
||||||
|
forwardedProto: req.headers.get('x-forwarded-proto'),
|
||||||
|
origin: req.headers.get('origin'),
|
||||||
|
referer: req.headers.get('referer'),
|
||||||
|
})
|
||||||
const response = NextResponse.json({ message: 'Logged out' })
|
const response = NextResponse.json({ message: 'Logged out' })
|
||||||
response.cookies.set(cookieConfig)
|
response.cookies.set(cookieConfig)
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ async function handler(req: NextRequest) {
|
|||||||
const userAgent = req.headers.get('user-agent') || undefined
|
const userAgent = req.headers.get('user-agent') || undefined
|
||||||
const ipAddress = req.headers.get('x-forwarded-for')?.split(',')[0]
|
const ipAddress = req.headers.get('x-forwarded-for')?.split(',')[0]
|
||||||
const token = await createSession(user.id, userAgent, ipAddress)
|
const token = await createSession(user.id, userAgent, ipAddress)
|
||||||
const cookieConfig = getSessionCookieConfig(token)
|
const cookieConfig = getSessionCookieConfig(token, {
|
||||||
|
forwardedProto: req.headers.get('x-forwarded-proto'),
|
||||||
|
origin: req.headers.get('origin'),
|
||||||
|
referer: req.headers.get('referer'),
|
||||||
|
})
|
||||||
|
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
user,
|
user,
|
||||||
|
|||||||
73
src/app/api/workspaces/[id]/contacts/[contactId]/route.ts
Normal file
73
src/app/api/workspaces/[id]/contacts/[contactId]/route.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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 { contactSchema } from '@/lib/validation'
|
||||||
|
|
||||||
|
export const PATCH = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, contactId } = 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.contact.findFirst({ where: { id: contactId, workspaceId, deletedAt: null } })
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const result = contactSchema.partial().safeParse(body)
|
||||||
|
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
|
||||||
|
|
||||||
|
const contact = await prisma.contact.update({
|
||||||
|
where: { id: contactId },
|
||||||
|
data: { ...result.data, updatedById: req.session.user.id },
|
||||||
|
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: 'CONTACT', entityId: contactId,
|
||||||
|
details: result.data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ contact })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update contact error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to update contact' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DELETE = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, contactId } = 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.contact.findFirst({ where: { id: contactId, workspaceId, deletedAt: null } })
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
await prisma.contact.update({ where: { id: contactId }, data: { deletedAt: new Date() } })
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'DELETE', entityType: 'CONTACT', entityId: contactId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete contact error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to delete contact' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
87
src/app/api/workspaces/[id]/contacts/route.ts
Normal file
87
src/app/api/workspaces/[id]/contacts/route.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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 { contactSchema } from '@/lib/validation'
|
||||||
|
|
||||||
|
export const GET = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const category = searchParams.get('category')
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
|
||||||
|
if (category) where.category = category
|
||||||
|
|
||||||
|
const contacts = await prisma.contact.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ isEmergency: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||||
|
include: {
|
||||||
|
createdBy: { select: { id: true, name: true } },
|
||||||
|
updatedBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ contacts })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('List contacts error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to list contacts' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const POST = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const result = contactSchema.safeParse(body)
|
||||||
|
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
|
||||||
|
|
||||||
|
const contact = await prisma.contact.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
name: result.data.name,
|
||||||
|
role: result.data.role,
|
||||||
|
category: result.data.category,
|
||||||
|
phone: result.data.phone,
|
||||||
|
phone2: result.data.phone2 || null,
|
||||||
|
email: result.data.email || null,
|
||||||
|
address: result.data.address || null,
|
||||||
|
hours: result.data.hours || null,
|
||||||
|
notes: result.data.notes || null,
|
||||||
|
isEmergency: result.data.isEmergency,
|
||||||
|
sortOrder: result.data.sortOrder,
|
||||||
|
createdById: req.session.user.id,
|
||||||
|
updatedById: req.session.user.id,
|
||||||
|
},
|
||||||
|
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: 'CREATE', entityType: 'CONTACT', entityId: contact.id,
|
||||||
|
details: { name: contact.name, category: contact.category },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ contact }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create contact error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to create contact' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
68
src/app/api/workspaces/[id]/documents/[docId]/route.ts
Normal file
68
src/app/api/workspaces/[id]/documents/[docId]/route.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
export const GET = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, docId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
const doc = await prisma.medicalDocument.findFirst({
|
||||||
|
where: { id: docId, workspaceId, deletedAt: null },
|
||||||
|
})
|
||||||
|
if (!doc) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
// Return the file data as a downloadable response
|
||||||
|
const uint8 = new Uint8Array(doc.fileData)
|
||||||
|
return new NextResponse(uint8, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': doc.mimeType,
|
||||||
|
'Content-Disposition': `inline; filename="${doc.fileName}"`,
|
||||||
|
'Content-Length': String(doc.fileSize),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download document error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to download document' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DELETE = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, docId } = 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.medicalDocument.findFirst({
|
||||||
|
where: { id: docId, workspaceId, deletedAt: null },
|
||||||
|
select: { id: true, title: true, category: true },
|
||||||
|
})
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
await prisma.medicalDocument.update({
|
||||||
|
where: { id: docId },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'DELETE', entityType: 'MEDICAL_DOCUMENT', entityId: docId,
|
||||||
|
details: { title: existing.title },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete document error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to delete document' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
129
src/app/api/workspaces/[id]/documents/route.ts
Normal file
129
src/app/api/workspaces/[id]/documents/route.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
||||||
|
const ALLOWED_TYPES = ['application/pdf', 'image/jpeg', 'image/png']
|
||||||
|
const VALID_CATEGORIES = ['LAB_REPORT', 'SCAN', 'INSURANCE', 'ID_CARD', 'PRESCRIPTION', 'OTHER']
|
||||||
|
|
||||||
|
export const GET = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const category = searchParams.get('category')
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200)
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
|
||||||
|
if (category && VALID_CATEGORIES.includes(category)) where.category = category
|
||||||
|
|
||||||
|
// Return metadata only — no file data in list
|
||||||
|
const documents = await prisma.medicalDocument.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
workspaceId: true,
|
||||||
|
title: true,
|
||||||
|
category: true,
|
||||||
|
fileName: true,
|
||||||
|
fileSize: true,
|
||||||
|
mimeType: true,
|
||||||
|
dateTaken: true,
|
||||||
|
expiryDate: true,
|
||||||
|
notes: true,
|
||||||
|
createdAt: true,
|
||||||
|
createdBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ documents })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('List documents error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to list documents' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const POST = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
const formData = await req.formData()
|
||||||
|
const file = formData.get('file') as File | null
|
||||||
|
const title = formData.get('title') as string | null
|
||||||
|
const category = formData.get('category') as string | null
|
||||||
|
const dateTaken = formData.get('dateTaken') as string | null
|
||||||
|
const expiryDate = formData.get('expiryDate') as string | null
|
||||||
|
const notes = formData.get('notes') as string | null
|
||||||
|
|
||||||
|
if (!file) return NextResponse.json({ error: 'File is required' }, { status: 400 })
|
||||||
|
if (!title?.trim()) return NextResponse.json({ error: 'Title is required' }, { status: 400 })
|
||||||
|
if (!category || !VALID_CATEGORIES.includes(category)) {
|
||||||
|
return NextResponse.json({ error: 'Valid category is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return NextResponse.json({ error: 'File too large (max 10MB)' }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
|
return NextResponse.json({ error: 'Only PDF, JPG, and PNG files allowed' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file into buffer
|
||||||
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
|
const fileData = Buffer.from(arrayBuffer)
|
||||||
|
|
||||||
|
const doc = await prisma.medicalDocument.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
title: title.trim(),
|
||||||
|
category,
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: file.size,
|
||||||
|
mimeType: file.type,
|
||||||
|
fileData,
|
||||||
|
dateTaken: dateTaken ? new Date(dateTaken) : null,
|
||||||
|
expiryDate: expiryDate ? new Date(expiryDate) : null,
|
||||||
|
notes: notes?.trim() || null,
|
||||||
|
createdById: req.session.user.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
category: true,
|
||||||
|
fileName: true,
|
||||||
|
fileSize: true,
|
||||||
|
mimeType: true,
|
||||||
|
dateTaken: true,
|
||||||
|
expiryDate: true,
|
||||||
|
notes: true,
|
||||||
|
createdAt: true,
|
||||||
|
createdBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'CREATE', entityType: 'MEDICAL_DOCUMENT', entityId: doc.id,
|
||||||
|
details: { title: doc.title, category: doc.category, fileSize: file.size },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ document: doc }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload document error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to upload document' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
83
src/app/api/workspaces/[id]/lab-results/[labId]/route.ts
Normal file
83
src/app/api/workspaces/[id]/lab-results/[labId]/route.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
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 { labResultSchema } from '@/lib/validation'
|
||||||
|
|
||||||
|
export const PATCH = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, labId } = 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.labResult.findFirst({ where: { id: labId, workspaceId, deletedAt: null } })
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const result = labResultSchema.partial().safeParse(body)
|
||||||
|
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = { updatedById: req.session.user.id }
|
||||||
|
if (result.data.testDate) updateData.testDate = new Date(result.data.testDate)
|
||||||
|
if (result.data.panelName !== undefined) updateData.panelName = result.data.panelName
|
||||||
|
if (result.data.labName !== undefined) updateData.labName = result.data.labName || null
|
||||||
|
if (result.data.results !== undefined) updateData.results = result.data.results as any
|
||||||
|
if (result.data.notes !== undefined) updateData.notes = result.data.notes || null
|
||||||
|
|
||||||
|
const labResult = await prisma.labResult.update({
|
||||||
|
where: { id: labId },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
createdBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'UPDATE', entityType: 'LAB_RESULT', entityId: labId,
|
||||||
|
details: { panelName: labResult.panelName },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ labResult })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update lab result error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to update lab result' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DELETE = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, labId } = 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.labResult.findFirst({ where: { id: labId, workspaceId, deletedAt: null } })
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
await prisma.labResult.update({
|
||||||
|
where: { id: labId },
|
||||||
|
data: { deletedAt: new Date(), updatedById: req.session.user.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'DELETE', entityType: 'LAB_RESULT', entityId: labId,
|
||||||
|
details: { panelName: existing.panelName },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete lab result error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to delete lab result' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
87
src/app/api/workspaces/[id]/lab-results/route.ts
Normal file
87
src/app/api/workspaces/[id]/lab-results/route.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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 { labResultSchema } from '@/lib/validation'
|
||||||
|
|
||||||
|
export const GET = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const from = searchParams.get('from')
|
||||||
|
const to = searchParams.get('to')
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200)
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
|
||||||
|
if (from || to) {
|
||||||
|
const dateFilter: Record<string, Date> = {}
|
||||||
|
if (from) dateFilter.gte = new Date(from)
|
||||||
|
if (to) dateFilter.lte = new Date(to)
|
||||||
|
where.testDate = dateFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
const labResults = await prisma.labResult.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { testDate: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
createdBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ labResults })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('List lab results error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to list lab results' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const POST = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const result = labResultSchema.safeParse(body)
|
||||||
|
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
|
||||||
|
|
||||||
|
const labResult = await prisma.labResult.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
testDate: new Date(result.data.testDate),
|
||||||
|
panelName: result.data.panelName,
|
||||||
|
labName: result.data.labName || null,
|
||||||
|
results: result.data.results as any,
|
||||||
|
notes: result.data.notes || null,
|
||||||
|
createdById: req.session.user.id,
|
||||||
|
updatedById: req.session.user.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
createdBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'CREATE', entityType: 'LAB_RESULT', entityId: labResult.id,
|
||||||
|
details: { panelName: labResult.panelName, markerCount: result.data.results.length },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ labResult }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create lab result error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to create lab result' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
66
src/app/api/workspaces/[id]/lab-results/trends/route.ts
Normal file
66
src/app/api/workspaces/[id]/lab-results/trends/route.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
interface StoredMarker {
|
||||||
|
marker: string
|
||||||
|
value: number
|
||||||
|
unit: string
|
||||||
|
refMin: number | null
|
||||||
|
refMax: number | null
|
||||||
|
flag: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const markerName = searchParams.get('marker')
|
||||||
|
if (!markerName) return NextResponse.json({ error: 'marker query param required' }, { status: 400 })
|
||||||
|
|
||||||
|
// Fetch all lab results with this marker
|
||||||
|
const labResults = await prisma.labResult.findMany({
|
||||||
|
where: { workspaceId, deletedAt: null },
|
||||||
|
orderBy: { testDate: 'asc' },
|
||||||
|
select: { testDate: true, results: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Extract the specific marker from each result
|
||||||
|
const trendData: Array<{
|
||||||
|
date: string
|
||||||
|
value: number
|
||||||
|
unit: string
|
||||||
|
refMin: number | null
|
||||||
|
refMax: number | null
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
for (const lr of labResults) {
|
||||||
|
const markers = lr.results as unknown as StoredMarker[]
|
||||||
|
if (!Array.isArray(markers)) continue
|
||||||
|
const found = markers.find(
|
||||||
|
(m) => m.marker.toLowerCase() === markerName.toLowerCase()
|
||||||
|
)
|
||||||
|
if (found) {
|
||||||
|
trendData.push({
|
||||||
|
date: lr.testDate.toISOString(),
|
||||||
|
value: found.value,
|
||||||
|
unit: found.unit,
|
||||||
|
refMin: found.refMin ?? null,
|
||||||
|
refMax: found.refMax ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ marker: markerName, trendData })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lab result trends error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch trends' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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 { checkInteractions } from '@/lib/interactions/checker'
|
||||||
|
|
||||||
|
export const POST = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
// Get all active medications for this workspace
|
||||||
|
const medications = await prisma.medication.findMany({
|
||||||
|
where: { workspaceId, active: true, deletedAt: null },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (medications.length < 2) {
|
||||||
|
return NextResponse.json({
|
||||||
|
interactions: [],
|
||||||
|
message: 'Need at least 2 active medications to check for interactions.',
|
||||||
|
medicationCount: medications.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const medNames = medications.map((m) => m.name)
|
||||||
|
const interactions = checkInteractions(medNames)
|
||||||
|
|
||||||
|
// Cache results in DB for quick retrieval
|
||||||
|
// Clear old interactions for this workspace first
|
||||||
|
await prisma.drugInteraction.deleteMany({ where: { workspaceId } })
|
||||||
|
|
||||||
|
// Save new interactions
|
||||||
|
if (interactions.length > 0) {
|
||||||
|
// Map drug names back to medication IDs
|
||||||
|
const nameToId = new Map(medications.map((m) => [m.name.toLowerCase(), m.id]))
|
||||||
|
|
||||||
|
for (const interaction of interactions) {
|
||||||
|
const med1Id = nameToId.get(interaction.drug1Name.toLowerCase())
|
||||||
|
const med2Id = nameToId.get(interaction.drug2Name.toLowerCase())
|
||||||
|
if (med1Id && med2Id) {
|
||||||
|
await prisma.drugInteraction.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
medication1Id: med1Id,
|
||||||
|
medication2Id: med2Id,
|
||||||
|
severity: interaction.severity,
|
||||||
|
description: interaction.description,
|
||||||
|
},
|
||||||
|
}).catch(() => {
|
||||||
|
// Ignore duplicate key errors
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'CREATE', entityType: 'DRUG_INTERACTION', entityId: workspaceId,
|
||||||
|
details: { medicationCount: medications.length, interactionsFound: interactions.length },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
interactions,
|
||||||
|
medicationCount: medications.length,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Check interactions error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to check interactions' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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 { milestoneSchema } from '@/lib/validation'
|
||||||
|
|
||||||
|
export const PATCH = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, milestoneId } = 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.treatmentMilestone.findFirst({ where: { id: milestoneId, workspaceId, deletedAt: null } })
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const result = milestoneSchema.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,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert date strings to Date objects
|
||||||
|
if (result.data.plannedDate) {
|
||||||
|
updateData.plannedDate = new Date(result.data.plannedDate)
|
||||||
|
}
|
||||||
|
if (result.data.actualDate !== undefined) {
|
||||||
|
updateData.actualDate = result.data.actualDate ? new Date(result.data.actualDate) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-set actualDate when completing
|
||||||
|
if (result.data.status === 'COMPLETED' && !existing.actualDate && !result.data.actualDate) {
|
||||||
|
updateData.actualDate = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
const milestone = await prisma.treatmentMilestone.update({
|
||||||
|
where: { id: milestoneId },
|
||||||
|
data: updateData,
|
||||||
|
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: 'MILESTONE', entityId: milestoneId,
|
||||||
|
details: result.data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ milestone })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update milestone error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to update milestone' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DELETE = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, milestoneId } = 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.treatmentMilestone.findFirst({ where: { id: milestoneId, workspaceId, deletedAt: null } })
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
await prisma.treatmentMilestone.update({ where: { id: milestoneId }, data: { deletedAt: new Date() } })
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'DELETE', entityType: 'MILESTONE', entityId: milestoneId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete milestone error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to delete milestone' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
84
src/app/api/workspaces/[id]/milestones/route.ts
Normal file
84
src/app/api/workspaces/[id]/milestones/route.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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 { milestoneSchema } from '@/lib/validation'
|
||||||
|
|
||||||
|
export const GET = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const status = searchParams.get('status')
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
|
||||||
|
if (status) {
|
||||||
|
where.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
const milestones = await prisma.treatmentMilestone.findMany({
|
||||||
|
where, orderBy: { plannedDate: 'asc' }, take: limit,
|
||||||
|
include: { createdBy: { select: { id: true, name: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ milestones })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('List milestones error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to list milestones' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const POST = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const result = milestoneSchema.safeParse(body)
|
||||||
|
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
|
||||||
|
|
||||||
|
const existingCount = await prisma.treatmentMilestone.count({
|
||||||
|
where: { workspaceId, deletedAt: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const milestone = await prisma.treatmentMilestone.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
type: result.data.type,
|
||||||
|
title: result.data.title,
|
||||||
|
description: result.data.description || null,
|
||||||
|
plannedDate: new Date(result.data.plannedDate),
|
||||||
|
actualDate: result.data.actualDate ? new Date(result.data.actualDate) : null,
|
||||||
|
status: result.data.status || 'SCHEDULED',
|
||||||
|
notes: result.data.notes || null,
|
||||||
|
sortOrder: existingCount,
|
||||||
|
createdById: req.session.user.id,
|
||||||
|
updatedById: req.session.user.id,
|
||||||
|
},
|
||||||
|
include: { createdBy: { select: { id: true, name: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'CREATE', entityType: 'MILESTONE', entityId: milestone.id,
|
||||||
|
details: { type: milestone.type, title: milestone.title },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ milestone }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create milestone error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to create milestone' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
45
src/app/api/workspaces/[id]/tasks/[taskId]/complete/route.ts
Normal file
45
src/app/api/workspaces/[id]/tasks/[taskId]/complete/route.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
export const POST = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, taskId } = 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.caregiverTask.findFirst({ where: { id: taskId, workspaceId, deletedAt: null } })
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
const task = await prisma.caregiverTask.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: {
|
||||||
|
status: 'DONE',
|
||||||
|
completedAt: new Date(),
|
||||||
|
completedById: req.session.user.id,
|
||||||
|
updatedById: req.session.user.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
assignedTo: { select: { id: true, name: true } },
|
||||||
|
createdBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'UPDATE', entityType: 'CAREGIVER_TASK', entityId: taskId,
|
||||||
|
details: { status: 'DONE' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ task })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Complete caregiver task error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to complete task' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
93
src/app/api/workspaces/[id]/tasks/[taskId]/route.ts
Normal file
93
src/app/api/workspaces/[id]/tasks/[taskId]/route.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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 { caregiverTaskSchema } from '@/lib/validation'
|
||||||
|
|
||||||
|
export const PATCH = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, taskId } = 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.caregiverTask.findFirst({ where: { id: taskId, workspaceId, deletedAt: null } })
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const result = caregiverTaskSchema.partial().safeParse(body)
|
||||||
|
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
|
||||||
|
|
||||||
|
// Build update data
|
||||||
|
const updateData: Record<string, unknown> = {
|
||||||
|
...result.data,
|
||||||
|
updatedById: req.session.user.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert dueDate string to Date if provided
|
||||||
|
if (result.data.dueDate !== undefined) {
|
||||||
|
updateData.dueDate = result.data.dueDate ? new Date(result.data.dueDate) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle completedAt based on status changes
|
||||||
|
if (result.data.status === 'DONE' && existing.status !== 'DONE' && !existing.completedAt) {
|
||||||
|
updateData.completedAt = new Date()
|
||||||
|
updateData.completedById = req.session.user.id
|
||||||
|
} else if (result.data.status && result.data.status !== 'DONE' && existing.status === 'DONE') {
|
||||||
|
updateData.completedAt = null
|
||||||
|
updateData.completedById = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await prisma.caregiverTask.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
assignedTo: { select: { id: true, name: true } },
|
||||||
|
createdBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'UPDATE', entityType: 'CAREGIVER_TASK', entityId: taskId,
|
||||||
|
details: result.data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ task })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update caregiver task error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to update task' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DELETE = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, taskId } = 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.caregiverTask.findFirst({ where: { id: taskId, workspaceId, deletedAt: null } })
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
await prisma.caregiverTask.update({ where: { id: taskId }, data: { deletedAt: new Date() } })
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'DELETE', entityType: 'CAREGIVER_TASK', entityId: taskId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete caregiver task error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to delete task' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
101
src/app/api/workspaces/[id]/tasks/route.ts
Normal file
101
src/app/api/workspaces/[id]/tasks/route.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
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 { caregiverTaskSchema } from '@/lib/validation'
|
||||||
|
|
||||||
|
const PRIORITY_ORDER: Record<string, number> = {
|
||||||
|
URGENT: 0,
|
||||||
|
HIGH: 1,
|
||||||
|
NORMAL: 2,
|
||||||
|
LOW: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const status = searchParams.get('status')
|
||||||
|
const assignedTo = searchParams.get('assignedTo')
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
|
||||||
|
if (status) where.status = status
|
||||||
|
if (assignedTo) where.assignedToId = assignedTo
|
||||||
|
|
||||||
|
const tasks = await prisma.caregiverTask.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
assignedTo: { select: { id: true, name: true } },
|
||||||
|
createdBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by priority order (URGENT first), then by createdAt desc
|
||||||
|
tasks.sort((a: { priority: string; createdAt: Date }, b: { priority: string; createdAt: Date }) => {
|
||||||
|
const priorityDiff = (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99)
|
||||||
|
if (priorityDiff !== 0) return priorityDiff
|
||||||
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ tasks })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('List caregiver tasks error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to list tasks' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const POST = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const result = caregiverTaskSchema.safeParse(body)
|
||||||
|
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
|
||||||
|
|
||||||
|
const task = await prisma.caregiverTask.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
title: result.data.title,
|
||||||
|
description: result.data.description || null,
|
||||||
|
category: result.data.category,
|
||||||
|
priority: result.data.priority || 'NORMAL',
|
||||||
|
status: result.data.status || 'TODO',
|
||||||
|
assignedToId: result.data.assignedToId || null,
|
||||||
|
dueDate: result.data.dueDate ? new Date(result.data.dueDate) : null,
|
||||||
|
createdById: req.session.user.id,
|
||||||
|
updatedById: req.session.user.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
assignedTo: { select: { id: true, name: true } },
|
||||||
|
createdBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'CREATE', entityType: 'CAREGIVER_TASK', entityId: task.id,
|
||||||
|
details: { title: task.title, category: task.category, priority: task.priority },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ task }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create caregiver task error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to create task' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
32
src/app/api/workspaces/[id]/temperature/[tempId]/route.ts
Normal file
32
src/app/api/workspaces/[id]/temperature/[tempId]/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
export const DELETE = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, tempId } = 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.temperatureLog.findFirst({ where: { id: tempId, workspaceId, deletedAt: null } })
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
await prisma.temperatureLog.update({ where: { id: tempId }, data: { deletedAt: new Date() } })
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'DELETE', entityType: 'TEMPERATURE_LOG', entityId: tempId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete temperature log error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to delete temperature log' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
78
src/app/api/workspaces/[id]/temperature/route.ts
Normal file
78
src/app/api/workspaces/[id]/temperature/route.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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 { temperatureLogSchema } from '@/lib/validation'
|
||||||
|
|
||||||
|
export const GET = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const from = searchParams.get('from')
|
||||||
|
const to = searchParams.get('to')
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
|
||||||
|
if (from || to) {
|
||||||
|
where.recordedAt = {}
|
||||||
|
if (from) (where.recordedAt as Record<string, unknown>).gte = new Date(from)
|
||||||
|
if (to) (where.recordedAt as Record<string, unknown>).lte = new Date(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
const temperatureLogs = await prisma.temperatureLog.findMany({
|
||||||
|
where, orderBy: { recordedAt: 'desc' }, take: limit,
|
||||||
|
include: { createdBy: { select: { id: true, name: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ temperatureLogs })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('List temperature logs error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to list temperature logs' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const POST = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const result = temperatureLogSchema.safeParse(body)
|
||||||
|
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
|
||||||
|
|
||||||
|
const temperatureLog = await prisma.temperatureLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
tempCelsius: result.data.tempCelsius,
|
||||||
|
method: result.data.method || null,
|
||||||
|
notes: result.data.notes || null,
|
||||||
|
recordedAt: result.data.recordedAt ? new Date(result.data.recordedAt) : new Date(),
|
||||||
|
createdById: req.session.user.id,
|
||||||
|
},
|
||||||
|
include: { createdBy: { select: { id: true, name: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'CREATE', entityType: 'TEMPERATURE_LOG', entityId: temperatureLog.id,
|
||||||
|
details: { tempCelsius: temperatureLog.tempCelsius, method: temperatureLog.method },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ temperatureLog }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create temperature log error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to create temperature log' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
32
src/app/api/workspaces/[id]/weight/[weightId]/route.ts
Normal file
32
src/app/api/workspaces/[id]/weight/[weightId]/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
export const DELETE = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, weightId } = 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.weightLog.findFirst({ where: { id: weightId, workspaceId, deletedAt: null } })
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
await prisma.weightLog.update({ where: { id: weightId }, data: { deletedAt: new Date() } })
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'DELETE', entityType: 'WEIGHT_LOG', entityId: weightId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete weight log error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to delete weight log' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
77
src/app/api/workspaces/[id]/weight/route.ts
Normal file
77
src/app/api/workspaces/[id]/weight/route.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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 { weightLogSchema } from '@/lib/validation'
|
||||||
|
|
||||||
|
export const GET = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const from = searchParams.get('from')
|
||||||
|
const to = searchParams.get('to')
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
|
||||||
|
if (from || to) {
|
||||||
|
where.recordedAt = {}
|
||||||
|
if (from) (where.recordedAt as Record<string, unknown>).gte = new Date(from)
|
||||||
|
if (to) (where.recordedAt as Record<string, unknown>).lte = new Date(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
const weightLogs = await prisma.weightLog.findMany({
|
||||||
|
where, orderBy: { recordedAt: 'desc' }, take: limit,
|
||||||
|
include: { createdBy: { select: { id: true, name: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ weightLogs })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('List weight logs error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to list weight logs' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const POST = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const result = weightLogSchema.safeParse(body)
|
||||||
|
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
|
||||||
|
|
||||||
|
const weightLog = await prisma.weightLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
weightKg: result.data.weightKg,
|
||||||
|
notes: result.data.notes || null,
|
||||||
|
recordedAt: result.data.recordedAt ? new Date(result.data.recordedAt) : new Date(),
|
||||||
|
createdById: req.session.user.id,
|
||||||
|
},
|
||||||
|
include: { createdBy: { select: { id: true, name: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
workspaceId, userId: req.session.user.id,
|
||||||
|
action: 'CREATE', entityType: 'WEIGHT_LOG', entityId: weightLog.id,
|
||||||
|
details: { weightKg: weightLog.weightKg },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ weightLog }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create weight log error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to create weight log' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -4,10 +4,14 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Google Fonts */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=Source+Sans+3:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap');
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 250 251 252;
|
--background: 250 247 242;
|
||||||
--foreground: 31 38 49;
|
--foreground: 38 35 32;
|
||||||
|
--surface: 255 255 255;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -17,6 +21,16 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-secondary-900 antialiased;
|
@apply bg-background text-secondary-900 antialiased;
|
||||||
font-feature-settings: 'rlig' 1, 'calt' 1;
|
font-feature-settings: 'rlig' 1, 'calt' 1;
|
||||||
|
font-family: 'Source Sans 3', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paper texture background */
|
||||||
|
.paper-texture {
|
||||||
|
background-color: #faf7f2;
|
||||||
|
background-image:
|
||||||
|
url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||||
|
background-blend-mode: soft-light;
|
||||||
|
background-size: 200px 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Large text mode */
|
/* Large text mode */
|
||||||
@@ -38,12 +52,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.large-text .text-sm {
|
.large-text .text-sm {
|
||||||
font-size: 1rem; /* text-base equivalent */
|
font-size: 1rem;
|
||||||
line-height: 1.5rem;
|
line-height: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.large-text .text-xs {
|
.large-text .text-xs {
|
||||||
font-size: 0.875rem; /* text-sm equivalent */
|
font-size: 0.875rem;
|
||||||
line-height: 1.25rem;
|
line-height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,9 +70,9 @@
|
|||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Focus styles for accessibility */
|
/* Focus styles for accessibility - warm glow */
|
||||||
*:focus-visible {
|
*:focus-visible {
|
||||||
@apply outline-none ring-2 ring-primary-500 ring-offset-2;
|
@apply outline-none ring-2 ring-primary-300 ring-offset-2 ring-offset-background;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Better touch targets */
|
/* Better touch targets */
|
||||||
@@ -79,64 +93,151 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
/* Primary taken button */
|
/* Warm sanctuary card styles */
|
||||||
.btn-taken {
|
.card-sanctuary {
|
||||||
|
@apply bg-surface rounded-card shadow-card;
|
||||||
|
@apply border border-cream-200/50;
|
||||||
|
@apply transition-all duration-300 ease-sanctuary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sanctuary:hover {
|
||||||
|
@apply shadow-card-hover;
|
||||||
|
@apply border-cream-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary action button - warm green */
|
||||||
|
.btn-primary {
|
||||||
@apply bg-primary-500 hover:bg-primary-600 text-white font-semibold;
|
@apply bg-primary-500 hover:bg-primary-600 text-white font-semibold;
|
||||||
@apply py-3 px-6 rounded-button min-h-touch;
|
@apply py-3.5 px-6 rounded-button min-h-touch;
|
||||||
@apply shadow-button transition-all duration-200;
|
@apply shadow-button transition-all duration-300 ease-sanctuary;
|
||||||
@apply active:scale-95;
|
@apply active:scale-[0.98] hover:shadow-button-hover;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card styles */
|
/* Secondary button - cream */
|
||||||
|
.btn-secondary {
|
||||||
|
@apply bg-cream-100 hover:bg-cream-200 text-secondary-800 font-medium;
|
||||||
|
@apply py-3.5 px-6 rounded-button min-h-touch;
|
||||||
|
@apply border border-cream-300;
|
||||||
|
@apply transition-all duration-300 ease-sanctuary;
|
||||||
|
@apply active:scale-[0.98];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accent button - terracotta */
|
||||||
|
.btn-accent {
|
||||||
|
@apply bg-accent-500 hover:bg-accent-600 text-white font-semibold;
|
||||||
|
@apply py-3.5 px-6 rounded-button min-h-touch;
|
||||||
|
@apply shadow-button transition-all duration-300 ease-sanctuary;
|
||||||
|
@apply active:scale-[0.98] hover:shadow-button-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ghost button */
|
||||||
|
.btn-ghost {
|
||||||
|
@apply bg-transparent hover:bg-cream-100 text-secondary-700 font-medium;
|
||||||
|
@apply py-3.5 px-6 rounded-button min-h-touch;
|
||||||
|
@apply transition-all duration-300 ease-sanctuary;
|
||||||
|
@apply active:scale-[0.98];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Taken button */
|
||||||
|
.btn-taken {
|
||||||
|
@apply btn-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Appointment card */
|
||||||
.card-appointment {
|
.card-appointment {
|
||||||
@apply bg-surface rounded-card shadow-card p-4;
|
@apply card-sanctuary p-5;
|
||||||
@apply border-l-4 border-primary-500;
|
@apply border-l-[6px] border-l-primary-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Medication card */
|
||||||
.card-medication {
|
.card-medication {
|
||||||
@apply bg-surface rounded-card shadow-card p-4;
|
@apply card-sanctuary p-5;
|
||||||
@apply flex items-center justify-between;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Overdue styles */
|
/* Overdue styles - warm terracotta */
|
||||||
.overdue {
|
.overdue {
|
||||||
@apply border-l-4 border-red-500 bg-red-50;
|
@apply border-l-[6px] border-l-accent-500;
|
||||||
|
@apply bg-accent-50/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Emergency card - alert red but softened */
|
||||||
|
.card-emergency {
|
||||||
|
@apply bg-alert-50 border-2 border-alert-200 rounded-card-lg;
|
||||||
|
@apply overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section styling */
|
||||||
|
.section-warm {
|
||||||
|
@apply bg-surface rounded-card-lg shadow-soft p-6;
|
||||||
|
@apply border border-cream-200/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input styling */
|
||||||
|
.input-sanctuary {
|
||||||
|
@apply bg-surface border border-cream-300 rounded-button;
|
||||||
|
@apply px-4 py-3.5 text-secondary-900 placeholder:text-secondary-400;
|
||||||
|
@apply focus:outline-none focus:border-primary-400 focus:ring-2 focus:ring-primary-200;
|
||||||
|
@apply transition-all duration-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Timeline styles */
|
/* Timeline styles */
|
||||||
.timeline-item {
|
.timeline-item {
|
||||||
@apply relative pl-6 pb-4;
|
@apply relative pl-8 pb-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-item::before {
|
.timeline-item::before {
|
||||||
content: '';
|
content: '';
|
||||||
@apply absolute left-0 top-2 w-2 h-2 rounded-full bg-primary-400;
|
@apply absolute left-0 top-2 w-3 h-3 rounded-full bg-primary-300;
|
||||||
|
@apply ring-4 ring-primary-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-item::after {
|
.timeline-item::after {
|
||||||
content: '';
|
content: '';
|
||||||
@apply absolute left-[3px] top-4 w-0.5 h-full bg-border;
|
@apply absolute left-[5px] top-5 w-0.5 h-full bg-cream-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-item:last-child::after {
|
.timeline-item:last-child::after {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Glass effect for overlays */
|
||||||
|
.glass {
|
||||||
|
@apply bg-surface/80 backdrop-blur-md;
|
||||||
|
@apply border border-cream-200/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Decorative blob shapes */
|
||||||
|
.blob {
|
||||||
|
@apply absolute rounded-full blur-3xl opacity-30 pointer-events-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob-primary {
|
||||||
|
@apply bg-primary-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob-accent {
|
||||||
|
@apply bg-accent-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob-cream {
|
||||||
|
@apply bg-cream-300;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
/* Animation utilities */
|
/* Animation utilities */
|
||||||
.animate-in {
|
.animate-in {
|
||||||
animation: animateIn 0.2s ease-out;
|
animation: animateIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-out {
|
.animate-out {
|
||||||
animation: animateOut 0.15s ease-in forwards;
|
animation: animateOut 0.2s ease-in forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes animateIn {
|
@keyframes animateIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(8px);
|
transform: translateY(12px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -151,55 +252,46 @@
|
|||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(8px);
|
transform: translateY(12px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-in-from-bottom-4 {
|
/* Stagger animation delays */
|
||||||
animation: slideInFromBottom 0.2s ease-out;
|
.stagger-1 { animation-delay: 0.05s; }
|
||||||
}
|
.stagger-2 { animation-delay: 0.1s; }
|
||||||
|
.stagger-3 { animation-delay: 0.15s; }
|
||||||
.slide-out-to-bottom-4 {
|
.stagger-4 { animation-delay: 0.2s; }
|
||||||
animation: slideOutToBottom 0.15s ease-in forwards;
|
.stagger-5 { animation-delay: 0.25s; }
|
||||||
}
|
.stagger-6 { animation-delay: 0.3s; }
|
||||||
|
|
||||||
@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 utilities */
|
||||||
.fade-in {
|
.fade-in {
|
||||||
animation: fadeIn 0.2s ease-out;
|
animation: fadeIn 0.3s ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-up {
|
||||||
|
animation: fadeUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scale utilities */
|
||||||
.zoom-in-95 {
|
.zoom-in-95 {
|
||||||
animation: zoomIn 0.2s ease-out;
|
animation: zoomIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes zoomIn {
|
@keyframes zoomIn {
|
||||||
@@ -212,4 +304,29 @@
|
|||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Soft pulse for live elements */
|
||||||
|
.pulse-soft {
|
||||||
|
animation: pulseSoft 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseSoft {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient text */
|
||||||
|
.gradient-text {
|
||||||
|
@apply bg-clip-text text-transparent;
|
||||||
|
background-image: linear-gradient(135deg, #528252 0%, #3f663f 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar but keep functionality */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import type { Metadata, Viewport } from 'next'
|
import type { Metadata, Viewport } from 'next'
|
||||||
import { Inter } from 'next/font/google'
|
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
import { Toaster } from '@/components/ui'
|
import { Toaster } from '@/components/ui'
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] })
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Next Step - Health Management',
|
title: 'Next Step - Health Management',
|
||||||
description: 'A calm, reliable app to help manage appointments, medications, and notes for health care.',
|
description: 'A calm, reliable app to help manage appointments, medications, and notes for health care.',
|
||||||
@@ -21,7 +18,7 @@ export const viewport: Viewport = {
|
|||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
maximumScale: 1,
|
maximumScale: 1,
|
||||||
userScalable: false,
|
userScalable: false,
|
||||||
themeColor: '#3a9563',
|
themeColor: '#528252',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -33,8 +30,11 @@ export default function RootLayout({
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
|
{/* Preconnect to Google Fonts for performance */}
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
</head>
|
</head>
|
||||||
<body className={inter.className}>
|
<body className="paper-texture">
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function LoginForm() {
|
|||||||
const response = await fetch('/api/auth/login', {
|
const response = await fetch('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -42,6 +43,15 @@ function LoginForm() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionResponse = await fetch('/api/auth/me', {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!sessionResponse.ok) {
|
||||||
|
throw new Error('Your session was created but is not available yet. Please try again.')
|
||||||
|
}
|
||||||
|
|
||||||
showToast('Welcome back!', 'success')
|
showToast('Welcome back!', 'success')
|
||||||
// If there's a redirect param (e.g., from invite link), go there
|
// If there's a redirect param (e.g., from invite link), go there
|
||||||
router.push(redirectTo || '/today')
|
router.push(redirectTo || '/today')
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Heart, Shield, ArrowRight } from 'lucide-react'
|
import { Heart, Shield, ArrowRight, Sparkles, Users, Bell } from 'lucide-react'
|
||||||
import { Button, Input, Card, showToast } from '@/components/ui'
|
import { Button, Input, showToast } from '@/components/ui'
|
||||||
|
|
||||||
export default function OnboardingPage() {
|
export default function OnboardingPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -12,6 +12,11 @@ export default function OnboardingPage() {
|
|||||||
const [clinicPhone, setClinicPhone] = useState('')
|
const [clinicPhone, setClinicPhone] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleAcceptDisclaimer = () => {
|
const handleAcceptDisclaimer = () => {
|
||||||
setStep('workspace')
|
setStep('workspace')
|
||||||
@@ -45,7 +50,7 @@ export default function OnboardingPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('All set! Welcome to Next Step.', 'success')
|
showToast('Welcome to Next Step', 'success')
|
||||||
router.push('/today')
|
router.push('/today')
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -57,101 +62,176 @@ export default function OnboardingPage() {
|
|||||||
|
|
||||||
if (step === 'disclaimer') {
|
if (step === 'disclaimer') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
|
<div className="min-h-screen paper-texture flex flex-col items-center justify-center p-6">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="text-center mb-8">
|
{/* Decorative blobs */}
|
||||||
<div className="w-16 h-16 bg-amber-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
<Shield className="w-8 h-8 text-amber-600" />
|
<div className="blob blob-primary w-96 h-96 -top-48 -right-48" />
|
||||||
</div>
|
<div className="blob blob-accent w-80 h-80 bottom-20 -left-40" />
|
||||||
<h1 className="text-2xl font-bold text-secondary-900">Important Notice</h1>
|
<div className="blob blob-cream w-64 h-64 top-1/2 right-1/4" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="mb-6">
|
<div className={`relative transition-all duration-1000 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||||
<div className="space-y-4 text-secondary-700">
|
{/* Logo/Icon */}
|
||||||
<p>
|
<div className="text-center mb-8">
|
||||||
<strong>Next Step is a tracking tool only.</strong> It helps you and your family
|
<div className="w-24 h-24 rounded-card-lg bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center mx-auto mb-6 shadow-elevated">
|
||||||
stay organized with appointments and medications.
|
<Heart className="w-12 h-12 text-white" />
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
<p>
|
<h1 className="font-display text-display-md text-secondary-900 mb-2">
|
||||||
<strong className="text-red-600">This app does not provide medical advice.</strong>{' '}
|
Next Step
|
||||||
Always consult your healthcare team for medical decisions.
|
</h1>
|
||||||
|
<p className="text-secondary-500 text-lg">
|
||||||
|
Supporting you through every step
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p>
|
{/* Disclaimer Card */}
|
||||||
<strong>For emergencies:</strong> Call 000 (Australia) or your local emergency
|
<div className="section-warm mb-6">
|
||||||
services immediately.
|
<div className="flex items-center gap-3 mb-6">
|
||||||
</p>
|
<div className="w-12 h-12 rounded-full bg-accent-100 flex items-center justify-center">
|
||||||
|
<Shield className="w-6 h-6 text-accent-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-xl text-secondary-900">Important Notice</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p>
|
<div className="space-y-4 text-secondary-700">
|
||||||
If you have questions about your treatment, contact your clinic directly using the
|
<div className="flex gap-3">
|
||||||
button we'll help you set up.
|
<Sparkles className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" />
|
||||||
</p>
|
<p>
|
||||||
|
<strong className="text-secondary-900">Next Step is a tracking tool only.</strong>{' '}
|
||||||
|
It helps you and your family stay organized with appointments and medications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="pt-4 border-t border-border">
|
<div className="flex gap-3">
|
||||||
<p className="text-sm text-secondary-500">
|
<div className="w-5 h-5 rounded-full bg-alert-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
By continuing, you acknowledge that Next Step is for tracking purposes only and
|
<span className="text-alert-600 text-xs font-bold">!</span>
|
||||||
does not replace professional medical advice.
|
</div>
|
||||||
|
<p>
|
||||||
|
<strong className="text-alert-600">This app does not provide medical advice.</strong>{' '}
|
||||||
|
Always consult your healthcare team for medical decisions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-alert-500 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<span className="text-white text-xs font-bold">000</span>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<strong>For emergencies:</strong> Call 000 (Australia) or your local emergency services immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Users className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<p>
|
||||||
|
Have questions about your treatment? Contact your clinic directly using the button we'll help you set up.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-6 border-t border-cream-200">
|
||||||
|
<p className="text-sm text-secondary-500 text-center">
|
||||||
|
By continuing, you acknowledge that Next Step is for tracking purposes only and does not replace professional medical advice.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Button onClick={handleAcceptDisclaimer} fullWidth>
|
<button
|
||||||
I Understand
|
onClick={handleAcceptDisclaimer}
|
||||||
<ArrowRight className="w-5 h-5 ml-2" />
|
className="btn-primary w-full flex items-center justify-center gap-2 text-lg py-4"
|
||||||
</Button>
|
>
|
||||||
|
I Understand
|
||||||
|
<ArrowRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
|
<div className="min-h-screen paper-texture flex flex-col items-center justify-center p-6">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-md">
|
||||||
<div className="text-center mb-8">
|
{/* Decorative blobs */}
|
||||||
<div className="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
<Heart className="w-8 h-8 text-white" />
|
<div className="blob blob-primary w-80 h-80 -top-32 right-0" />
|
||||||
</div>
|
<div className="blob blob-cream w-64 h-64 bottom-0 left-0" />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<Card className="mb-6">
|
<div className={`relative transition-all duration-700 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||||
<form onSubmit={handleCreateWorkspace} className="space-y-4">
|
{/* Header */}
|
||||||
<Input
|
<div className="text-center mb-8">
|
||||||
label="Workspace Name"
|
<div className="w-20 h-20 rounded-card-lg bg-gradient-to-br from-accent-400 to-accent-500 flex items-center justify-center mx-auto mb-6 shadow-elevated">
|
||||||
type="text"
|
<Sparkles className="w-10 h-10 text-white" />
|
||||||
value={workspaceName}
|
</div>
|
||||||
onChange={(e) => setWorkspaceName(e.target.value)}
|
|
||||||
placeholder="e.g., Grace's Plan"
|
<h1 className="font-display text-display-sm text-secondary-900 mb-2">
|
||||||
helperText="This is how family members will identify this workspace"
|
Set Up Your Plan
|
||||||
required
|
</h1>
|
||||||
/>
|
<p className="text-secondary-500 text-lg">Create a workspace to get started</p>
|
||||||
<Input
|
</div>
|
||||||
label="Clinic Phone Number"
|
|
||||||
type="tel"
|
{/* Form */}
|
||||||
value={clinicPhone}
|
<form onSubmit={handleCreateWorkspace} className="section-warm space-y-6">
|
||||||
onChange={(e) => setClinicPhone(e.target.value)}
|
<div>
|
||||||
placeholder="e.g., 08 9400 1234"
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
helperText="We'll add a 'Call Clinic' button for quick access"
|
Workspace Name
|
||||||
/>
|
<span className="text-alert-500 ml-1">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={workspaceName}
|
||||||
|
onChange={(e) => setWorkspaceName(e.target.value)}
|
||||||
|
placeholder="e.g., Grace's Plan"
|
||||||
|
className="input-sanctuary w-full"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-secondary-400 mt-2">
|
||||||
|
This is how family members will identify this workspace
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
|
Clinic Phone Number
|
||||||
|
<span className="text-secondary-400 font-normal ml-1">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Bell className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={clinicPhone}
|
||||||
|
onChange={(e) => setClinicPhone(e.target.value)}
|
||||||
|
placeholder="e.g., 08 9400 1234"
|
||||||
|
className="input-sanctuary w-full pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-secondary-400 mt-2">
|
||||||
|
We'll add a quick "Call Clinic" button for easy access
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
|
<div className="bg-alert-50 border border-alert-200 rounded-card p-4">
|
||||||
{error}
|
<p className="text-sm text-alert-700">{error}</p>
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" fullWidth loading={loading}>
|
<button
|
||||||
Create Workspace
|
type="submit"
|
||||||
</Button>
|
disabled={loading}
|
||||||
|
className="btn-primary w-full text-lg py-4 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Creating...' : 'Create Workspace'}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<p className="text-center text-sm text-secondary-500">
|
<p className="text-center text-sm text-secondary-400 mt-6">
|
||||||
You can add family members later from Settings
|
You can add family members later from Settings
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ function RegisterForm() {
|
|||||||
const response = await fetch('/api/auth/register', {
|
const response = await fetch('/api/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ name, email, password }),
|
body: JSON.stringify({ name, email, password }),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -47,6 +48,15 @@ function RegisterForm() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionResponse = await fetch('/api/auth/me', {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!sessionResponse.ok) {
|
||||||
|
throw new Error('Your account was created, but the session is not available yet. Please sign in again.')
|
||||||
|
}
|
||||||
|
|
||||||
showToast('Account created! Let\'s get started.', 'success')
|
showToast('Account created! Let\'s get started.', 'success')
|
||||||
// If there's a redirect param (e.g., from invite link), go there instead of onboarding
|
// If there's a redirect param (e.g., from invite link), go there instead of onboarding
|
||||||
router.push(redirectTo || '/onboarding')
|
router.push(redirectTo || '/onboarding')
|
||||||
|
|||||||
36
src/components/contacts/CategoryTabs.tsx
Normal file
36
src/components/contacts/CategoryTabs.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'ONCOLOGY', label: 'Oncology' },
|
||||||
|
{ value: 'HOSPITAL', label: 'Hospital' },
|
||||||
|
{ value: 'PHARMACY', label: 'Pharmacy' },
|
||||||
|
{ value: 'INSURANCE', label: 'Insurance' },
|
||||||
|
{ value: 'FAMILY', label: 'Family' },
|
||||||
|
{ value: 'OTHER', label: 'Other' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface CategoryTabsProps {
|
||||||
|
selected: string
|
||||||
|
onChange: (category: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryTabs({ selected, onChange }: CategoryTabsProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide">
|
||||||
|
{CATEGORIES.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.value}
|
||||||
|
onClick={() => onChange(cat.value)}
|
||||||
|
className={`flex-shrink-0 px-4 py-2 rounded-button text-sm font-medium transition-all border ${
|
||||||
|
selected === cat.value
|
||||||
|
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||||
|
: 'border-border text-secondary-600 hover:border-secondary-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
src/components/contacts/ContactCard.tsx
Normal file
113
src/components/contacts/ContactCard.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Phone, Mail, MapPin, Clock, ChevronDown, ChevronUp, Star } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Contact {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
category: string
|
||||||
|
phone: string
|
||||||
|
phone2: string | null
|
||||||
|
email: string | null
|
||||||
|
address: string | null
|
||||||
|
hours: string | null
|
||||||
|
notes: string | null
|
||||||
|
isEmergency: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactCardProps {
|
||||||
|
contact: Contact
|
||||||
|
onEdit?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
ONCOLOGY: 'bg-purple-100 text-purple-700',
|
||||||
|
HOSPITAL: 'bg-blue-100 text-blue-700',
|
||||||
|
PHARMACY: 'bg-green-100 text-green-700',
|
||||||
|
INSURANCE: 'bg-amber-100 text-amber-700',
|
||||||
|
FAMILY: 'bg-pink-100 text-pink-700',
|
||||||
|
OTHER: 'bg-secondary-100 text-secondary-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactCard({ contact, onEdit }: ContactCardProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const initial = contact.name.charAt(0).toUpperCase()
|
||||||
|
const categoryColor = CATEGORY_COLORS[contact.category] || CATEGORY_COLORS.OTHER
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-surface rounded-card border p-4 ${
|
||||||
|
contact.isEmergency ? 'border-red-200 bg-red-50/30' : 'border-border'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-bold ${categoryColor}`}>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name & Role */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-secondary-900 truncate">{contact.name}</h3>
|
||||||
|
{contact.isEmergency && <Star className="w-4 h-4 text-red-500 fill-red-500 flex-shrink-0" />}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-secondary-500">{contact.role}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Call Button */}
|
||||||
|
<a
|
||||||
|
href={`tel:${contact.phone}`}
|
||||||
|
className="flex items-center justify-center w-12 h-12 rounded-full bg-primary-500 text-white hover:bg-primary-600 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Phone className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Expand */}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="p-2 text-secondary-400 hover:text-secondary-600"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Details */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-4 pt-3 border-t border-border space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-secondary-600">
|
||||||
|
<Phone className="w-4 h-4 text-secondary-400" />
|
||||||
|
<a href={`tel:${contact.phone}`} className="text-primary-600 hover:underline">{contact.phone}</a>
|
||||||
|
</div>
|
||||||
|
{contact.phone2 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-secondary-600">
|
||||||
|
<Phone className="w-4 h-4 text-secondary-400" />
|
||||||
|
<a href={`tel:${contact.phone2}`} className="text-primary-600 hover:underline">{contact.phone2}</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contact.email && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-secondary-600">
|
||||||
|
<Mail className="w-4 h-4 text-secondary-400" />
|
||||||
|
<a href={`mailto:${contact.email}`} className="text-primary-600 hover:underline">{contact.email}</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contact.address && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-secondary-600">
|
||||||
|
<MapPin className="w-4 h-4 text-secondary-400" />
|
||||||
|
<span>{contact.address}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contact.hours && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-secondary-600">
|
||||||
|
<Clock className="w-4 h-4 text-secondary-400" />
|
||||||
|
<span>{contact.hours}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contact.notes && (
|
||||||
|
<p className="text-sm text-secondary-500 mt-2 pl-6">{contact.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
135
src/components/contacts/ContactForm.tsx
Normal file
135
src/components/contacts/ContactForm.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Modal, Button, Input, Select, showToast } from '@/components/ui'
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ value: 'ONCOLOGY', label: 'Oncology' },
|
||||||
|
{ value: 'HOSPITAL', label: 'Hospital' },
|
||||||
|
{ value: 'PHARMACY', label: 'Pharmacy' },
|
||||||
|
{ value: 'INSURANCE', label: 'Insurance' },
|
||||||
|
{ value: 'FAMILY', label: 'Family' },
|
||||||
|
{ value: 'OTHER', label: 'Other' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface ContactFormData {
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
category: string
|
||||||
|
phone: string
|
||||||
|
phone2: string
|
||||||
|
email: string
|
||||||
|
address: string
|
||||||
|
hours: string
|
||||||
|
notes: string
|
||||||
|
isEmergency: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactFormProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void
|
||||||
|
workspaceId: string
|
||||||
|
initialData?: Partial<ContactFormData> & { id?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactForm({ open, onClose, onSaved, workspaceId, initialData }: ContactFormProps) {
|
||||||
|
const isEdit = !!initialData?.id
|
||||||
|
const [form, setForm] = useState<ContactFormData>({
|
||||||
|
name: initialData?.name || '',
|
||||||
|
role: initialData?.role || '',
|
||||||
|
category: initialData?.category || 'ONCOLOGY',
|
||||||
|
phone: initialData?.phone || '',
|
||||||
|
phone2: initialData?.phone2 || '',
|
||||||
|
email: initialData?.email || '',
|
||||||
|
address: initialData?.address || '',
|
||||||
|
hours: initialData?.hours || '',
|
||||||
|
notes: initialData?.notes || '',
|
||||||
|
isEmergency: initialData?.isEmergency || false,
|
||||||
|
})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.name.trim() || !form.role.trim() || !form.phone.trim()) {
|
||||||
|
showToast('Name, role, and phone are required', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const url = isEdit
|
||||||
|
? `/api/workspaces/${workspaceId}/contacts/${initialData!.id}`
|
||||||
|
: `/api/workspaces/${workspaceId}/contacts`
|
||||||
|
const method = isEdit ? 'PATCH' : 'POST'
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: form.name.trim(),
|
||||||
|
role: form.role.trim(),
|
||||||
|
category: form.category,
|
||||||
|
phone: form.phone.trim(),
|
||||||
|
phone2: form.phone2.trim() || null,
|
||||||
|
email: form.email.trim() || null,
|
||||||
|
address: form.address.trim() || null,
|
||||||
|
hours: form.hours.trim() || null,
|
||||||
|
notes: form.notes.trim() || null,
|
||||||
|
isEmergency: form.isEmergency,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to save contact')
|
||||||
|
showToast(isEdit ? 'Contact updated' : 'Contact added', 'success')
|
||||||
|
onSaved()
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to save contact', 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = (field: keyof ContactFormData, value: string | boolean) =>
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={open} onClose={onClose} title={isEdit ? 'Edit Contact' : 'Add Contact'}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input label="Name *" value={form.name} onChange={(e) => update('name', e.target.value)} placeholder="Dr. Smith" />
|
||||||
|
<Input label="Role *" value={form.role} onChange={(e) => update('role', e.target.value)} placeholder="Oncologist" />
|
||||||
|
<Select label="Category" value={form.category} onChange={(e) => update('category', e.target.value)} options={CATEGORIES} />
|
||||||
|
<Input label="Phone *" value={form.phone} onChange={(e) => update('phone', e.target.value)} placeholder="+61 2 1234 5678" type="tel" />
|
||||||
|
<Input label="Secondary Phone" value={form.phone2} onChange={(e) => update('phone2', e.target.value)} placeholder="Optional" type="tel" />
|
||||||
|
<Input label="Email" value={form.email} onChange={(e) => update('email', e.target.value)} placeholder="Optional" type="email" />
|
||||||
|
<Input label="Address" value={form.address} onChange={(e) => update('address', e.target.value)} placeholder="Optional" />
|
||||||
|
<Input label="Hours" value={form.hours} onChange={(e) => update('hours', e.target.value)} placeholder="Mon-Fri 8am-5pm" />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => update('notes', e.target.value)}
|
||||||
|
placeholder="Additional info..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-3 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isEmergency}
|
||||||
|
onChange={(e) => update('isEmergency', e.target.checked)}
|
||||||
|
className="w-5 h-5 rounded border-border text-primary-500 focus:ring-primary-200"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-secondary-700">Mark as Emergency Contact</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
|
||||||
|
<Button onClick={handleSave} fullWidth loading={saving}>
|
||||||
|
{isEdit ? 'Update' : 'Add Contact'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
src/components/documents/DocumentCard.tsx
Normal file
100
src/components/documents/DocumentCard.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { format, isPast, addDays } from 'date-fns'
|
||||||
|
import { FileText, Image as ImageIcon, File } from 'lucide-react'
|
||||||
|
|
||||||
|
interface DocumentData {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
category: string
|
||||||
|
fileName: string
|
||||||
|
fileSize: number
|
||||||
|
mimeType: string
|
||||||
|
dateTaken: string | null
|
||||||
|
expiryDate: string | null
|
||||||
|
notes: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentCardProps {
|
||||||
|
document: DocumentData
|
||||||
|
onView: (doc: DocumentData) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_BADGES: Record<string, string> = {
|
||||||
|
LAB_REPORT: 'bg-blue-100 text-blue-700',
|
||||||
|
SCAN: 'bg-purple-100 text-purple-700',
|
||||||
|
INSURANCE: 'bg-green-100 text-green-700',
|
||||||
|
ID_CARD: 'bg-orange-100 text-orange-700',
|
||||||
|
PRESCRIPTION: 'bg-pink-100 text-pink-700',
|
||||||
|
OTHER: 'bg-secondary-100 text-secondary-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
LAB_REPORT: 'Lab Report',
|
||||||
|
SCAN: 'Scan',
|
||||||
|
INSURANCE: 'Insurance',
|
||||||
|
ID_CARD: 'ID Card',
|
||||||
|
PRESCRIPTION: 'Prescription',
|
||||||
|
OTHER: 'Other',
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileIcon({ mimeType }: { mimeType: string }) {
|
||||||
|
if (mimeType === 'application/pdf') return <FileText className="w-6 h-6 text-red-500" />
|
||||||
|
if (mimeType.startsWith('image/')) return <ImageIcon className="w-6 h-6 text-blue-500" />
|
||||||
|
return <File className="w-6 h-6 text-secondary-500" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentCard({ document: doc, onView }: DocumentCardProps) {
|
||||||
|
const badge = CATEGORY_BADGES[doc.category] || CATEGORY_BADGES.OTHER
|
||||||
|
const label = CATEGORY_LABELS[doc.category] || doc.category
|
||||||
|
const isExpiringSoon = doc.expiryDate && !isPast(new Date(doc.expiryDate)) &&
|
||||||
|
isPast(addDays(new Date(), -30))
|
||||||
|
const isExpired = doc.expiryDate && isPast(new Date(doc.expiryDate))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => onView(doc)}
|
||||||
|
className="bg-surface rounded-card border border-border p-4 cursor-pointer hover:shadow-card-hover transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center flex-shrink-0">
|
||||||
|
<FileIcon mimeType={doc.mimeType} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-secondary-900 truncate">{doc.title}</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
|
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${badge}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-secondary-400">
|
||||||
|
{formatFileSize(doc.fileSize)}
|
||||||
|
</span>
|
||||||
|
{doc.dateTaken && (
|
||||||
|
<span className="text-xs text-secondary-500">
|
||||||
|
{format(new Date(doc.dateTaken), 'MMM d, yyyy')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Expiry indicators */}
|
||||||
|
{isExpired && (
|
||||||
|
<span className="inline-block mt-1.5 text-xs font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-700">
|
||||||
|
Expired
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isExpiringSoon && !isExpired && (
|
||||||
|
<span className="inline-block mt-1.5 text-xs font-semibold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700">
|
||||||
|
Expiring soon
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
181
src/components/documents/DocumentUpload.tsx
Normal file
181
src/components/documents/DocumentUpload.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { Upload } from 'lucide-react'
|
||||||
|
import { Modal, Button, Input, Select, Textarea, showToast } from '@/components/ui'
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ value: 'LAB_REPORT', label: 'Lab Report' },
|
||||||
|
{ value: 'SCAN', label: 'Scan / Imaging' },
|
||||||
|
{ value: 'INSURANCE', label: 'Insurance' },
|
||||||
|
{ value: 'ID_CARD', label: 'ID Card' },
|
||||||
|
{ value: 'PRESCRIPTION', label: 'Prescription' },
|
||||||
|
{ value: 'OTHER', label: 'Other' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
||||||
|
const ACCEPTED_TYPES = '.pdf,.jpg,.jpeg,.png'
|
||||||
|
|
||||||
|
interface DocumentUploadProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void
|
||||||
|
workspaceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentUpload({ isOpen, onClose, onSaved, workspaceId }: DocumentUploadProps) {
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [file, setFile] = useState<File | null>(null)
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [category, setCategory] = useState('OTHER')
|
||||||
|
const [dateTaken, setDateTaken] = useState('')
|
||||||
|
const [expiryDate, setExpiryDate] = useState('')
|
||||||
|
const [notes, setNotes] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selected = e.target.files?.[0]
|
||||||
|
if (!selected) return
|
||||||
|
|
||||||
|
if (selected.size > MAX_FILE_SIZE) {
|
||||||
|
showToast('File too large (max 10MB)', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFile(selected)
|
||||||
|
if (!title) {
|
||||||
|
// Auto-fill title from filename
|
||||||
|
setTitle(selected.name.replace(/\.[^/.]+$/, '').replace(/[_-]/g, ' '))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!file) {
|
||||||
|
showToast('Please select a file', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!title.trim()) {
|
||||||
|
showToast('Title is required', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('title', title.trim())
|
||||||
|
formData.append('category', category)
|
||||||
|
if (dateTaken) formData.append('dateTaken', new Date(dateTaken).toISOString())
|
||||||
|
if (expiryDate) formData.append('expiryDate', new Date(expiryDate).toISOString())
|
||||||
|
if (notes.trim()) formData.append('notes', notes.trim())
|
||||||
|
|
||||||
|
const response = await fetch(`/api/workspaces/${workspaceId}/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({ error: 'Upload failed' }))
|
||||||
|
throw new Error(err.error || 'Upload failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Document uploaded', 'success')
|
||||||
|
onSaved()
|
||||||
|
handleReset()
|
||||||
|
onClose()
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast(err.message || 'Failed to upload document', 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setFile(null)
|
||||||
|
setTitle('')
|
||||||
|
setCategory('OTHER')
|
||||||
|
setDateTaken('')
|
||||||
|
setExpiryDate('')
|
||||||
|
setNotes('')
|
||||||
|
if (fileRef.current) fileRef.current.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="Upload Document">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* File picker */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-secondary-700 mb-2">File *</p>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_TYPES}
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="w-full border-2 border-dashed border-border rounded-card p-6 text-center hover:border-primary-300 transition-colors"
|
||||||
|
>
|
||||||
|
{file ? (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-secondary-900">{file.name}</p>
|
||||||
|
<p className="text-xs text-secondary-400 mt-1">
|
||||||
|
{(file.size / (1024 * 1024)).toFixed(1)} MB · Tap to change
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Upload className="w-8 h-8 text-secondary-300 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-secondary-500">Tap to select a file</p>
|
||||||
|
<p className="text-xs text-secondary-400 mt-1">PDF, JPG, or PNG · Max 10MB</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Title *"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g. Blood work results"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Category"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
options={CATEGORIES}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Date Taken"
|
||||||
|
type="date"
|
||||||
|
value={dateTaken}
|
||||||
|
onChange={(e) => setDateTaken(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Expiry Date"
|
||||||
|
type="date"
|
||||||
|
value={expiryDate}
|
||||||
|
onChange={(e) => setExpiryDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Additional notes..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
|
||||||
|
<Button onClick={handleSave} fullWidth loading={saving}>Upload</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
src/components/documents/DocumentViewer.tsx
Normal file
84
src/components/documents/DocumentViewer.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { X, Download, Trash2 } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui'
|
||||||
|
|
||||||
|
interface DocumentViewerProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onDelete?: () => void
|
||||||
|
document: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
mimeType: string
|
||||||
|
fileName: string
|
||||||
|
} | null
|
||||||
|
workspaceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentViewer({ isOpen, onClose, onDelete, document: doc, workspaceId }: DocumentViewerProps) {
|
||||||
|
if (!isOpen || !doc) return null
|
||||||
|
|
||||||
|
const fileUrl = `/api/workspaces/${workspaceId}/documents/${doc.id}`
|
||||||
|
const isImage = doc.mimeType.startsWith('image/')
|
||||||
|
const isPDF = doc.mimeType === 'application/pdf'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/90 flex flex-col">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-black/50">
|
||||||
|
<h2 className="text-white font-semibold truncate flex-1 mr-4">{doc.title}</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={fileUrl}
|
||||||
|
download={doc.fileName}
|
||||||
|
className="p-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-5 h-5 text-white" />
|
||||||
|
</a>
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="p-2 rounded-lg bg-white/10 hover:bg-red-500/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-5 h-5 text-white" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-auto flex items-center justify-center p-4">
|
||||||
|
{isImage && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={fileUrl}
|
||||||
|
alt={doc.title}
|
||||||
|
className="max-w-full max-h-full object-contain rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isPDF && (
|
||||||
|
<iframe
|
||||||
|
src={fileUrl}
|
||||||
|
title={doc.title}
|
||||||
|
className="w-full h-full rounded-lg bg-white"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isImage && !isPDF && (
|
||||||
|
<div className="text-center text-white">
|
||||||
|
<p className="text-lg mb-4">Preview not available</p>
|
||||||
|
<Button variant="secondary" onClick={() => window.open(fileUrl, '_blank')}>
|
||||||
|
Download File
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Phone, User, Droplets, AlertTriangle, Activity, Stethoscope } from 'lucide-react'
|
import { Phone, User, Droplets, AlertTriangle, Activity, Stethoscope, HeartPulse, FileText } from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
interface EmergencyInfo {
|
interface EmergencyInfo {
|
||||||
@@ -39,49 +39,66 @@ export function EmergencyCard({ info, medications, variant = 'full' }: Emergency
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-red-50 border-2 border-red-200 rounded-xl overflow-hidden">
|
<div className="bg-surface border-2 border-alert-200 rounded-card-lg overflow-hidden shadow-elevated">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-red-600 text-white px-4 py-3">
|
<div className="bg-gradient-to-r from-alert-500 to-alert-600 text-white px-6 py-5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<AlertTriangle className="w-6 h-6" />
|
<div className="w-12 h-12 rounded-full bg-white/20 flex items-center justify-center">
|
||||||
<h2 className="text-xl font-bold">Emergency Information</h2>
|
<AlertTriangle className="w-7 h-7" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-2xl">Emergency Information</h2>
|
||||||
|
<p className="text-alert-100 text-sm">Critical medical details for emergencies</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-6 space-y-6">
|
||||||
{/* Patient Info */}
|
{/* Patient Info */}
|
||||||
{info.patientName && (
|
{info.patientName && (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-4 bg-cream-50 rounded-card p-4 border border-cream-200">
|
||||||
<User className="w-5 h-5 text-red-600 mt-0.5" />
|
<div className="w-12 h-12 rounded-full bg-cream-200 flex items-center justify-center flex-shrink-0">
|
||||||
<div>
|
<User className="w-6 h-6 text-cream-700" />
|
||||||
<p className="text-sm text-red-700 font-medium">Patient Name</p>
|
</div>
|
||||||
<p className="text-lg font-bold text-secondary-900">{info.patientName}</p>
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-secondary-500 uppercase tracking-wide font-semibold mb-1">
|
||||||
|
Patient
|
||||||
|
</p>
|
||||||
|
<p className="font-display text-xl text-secondary-900">{info.patientName}</p>
|
||||||
{info.patientDOB && (
|
{info.patientDOB && (
|
||||||
<p className="text-sm text-secondary-600">DOB: {formatDate(info.patientDOB)}</p>
|
<p className="text-sm text-secondary-600 mt-1">
|
||||||
|
Born: {formatDate(info.patientDOB)}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Blood Type */}
|
{/* Blood Type - Large and prominent */}
|
||||||
{info.bloodType && (
|
{info.bloodType && (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-center gap-4">
|
||||||
<Droplets className="w-5 h-5 text-red-600 mt-0.5" />
|
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-alert-100 to-alert-200 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Droplets className="w-6 h-6 text-alert-600" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-red-700 font-medium">Blood Type</p>
|
<p className="text-xs text-secondary-500 uppercase tracking-wide font-semibold">
|
||||||
<p className="text-2xl font-bold text-red-600">{info.bloodType}</p>
|
Blood Type
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-display text-alert-600">{info.bloodType}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Allergies - High visibility */}
|
{/* Allergies - High visibility */}
|
||||||
{info.allergies && (
|
{info.allergies && (
|
||||||
<div className="bg-red-100 border border-red-300 rounded-lg p-3">
|
<div className="bg-alert-50 border-l-4 border-alert-500 rounded-r-card p-5">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<AlertTriangle className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" />
|
<AlertTriangle className="w-6 h-6 text-alert-600 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-red-700 font-bold uppercase">Allergies</p>
|
<p className="text-sm text-alert-700 font-bold uppercase tracking-wide mb-2">
|
||||||
<p className="text-secondary-900 font-medium mt-1">{info.allergies}</p>
|
Allergies
|
||||||
|
</p>
|
||||||
|
<p className="text-secondary-900 font-medium text-lg">{info.allergies}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,10 +106,14 @@ export function EmergencyCard({ info, medications, variant = 'full' }: Emergency
|
|||||||
|
|
||||||
{/* Medical Conditions */}
|
{/* Medical Conditions */}
|
||||||
{info.medicalConditions && (
|
{info.medicalConditions && (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-4">
|
||||||
<Activity className="w-5 h-5 text-red-600 mt-0.5" />
|
<div className="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||||
<div>
|
<HeartPulse className="w-6 h-6 text-primary-600" />
|
||||||
<p className="text-sm text-red-700 font-medium">Medical Conditions</p>
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-secondary-500 uppercase tracking-wide font-semibold mb-1">
|
||||||
|
Medical Conditions
|
||||||
|
</p>
|
||||||
<p className="text-secondary-900">{info.medicalConditions}</p>
|
<p className="text-secondary-900">{info.medicalConditions}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,34 +121,49 @@ export function EmergencyCard({ info, medications, variant = 'full' }: Emergency
|
|||||||
|
|
||||||
{/* Current Medications */}
|
{/* Current Medications */}
|
||||||
{variant === 'full' && medications && medications.length > 0 && (
|
{variant === 'full' && medications && medications.length > 0 && (
|
||||||
<div className="border-t border-red-200 pt-4">
|
<div className="border-t-2 border-cream-200 pt-6">
|
||||||
<p className="text-sm text-red-700 font-bold mb-2">Current Medications</p>
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<ul className="space-y-1">
|
<FileText className="w-5 h-5 text-primary-500" />
|
||||||
{medications.map((med, i) => (
|
<p className="text-sm text-secondary-700 font-semibold uppercase tracking-wide">
|
||||||
<li key={i} className="text-secondary-900">
|
Current Medications
|
||||||
<span className="font-medium">{med.name}</span>
|
</p>
|
||||||
{med.instructions && (
|
</div>
|
||||||
<span className="text-secondary-600"> - {med.instructions}</span>
|
<div className="bg-cream-50 rounded-card p-4 border border-cream-200">
|
||||||
)}
|
<ul className="space-y-3">
|
||||||
</li>
|
{medications.map((med, i) => (
|
||||||
))}
|
<li key={i} className="flex items-start gap-3">
|
||||||
</ul>
|
<span className="w-2 h-2 rounded-full bg-primary-400 mt-2 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-secondary-900">{med.name}</span>
|
||||||
|
{med.instructions && (
|
||||||
|
<span className="text-secondary-600"> — {med.instructions}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Doctor Info */}
|
{/* Doctor Info */}
|
||||||
{info.primaryPhysician && (
|
{info.primaryPhysician && (
|
||||||
<div className="border-t border-red-200 pt-4">
|
<div className="border-t-2 border-cream-200 pt-6">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-4">
|
||||||
<Stethoscope className="w-5 h-5 text-red-600 mt-0.5" />
|
<div className="w-12 h-12 rounded-full bg-secondary-100 flex items-center justify-center flex-shrink-0">
|
||||||
<div>
|
<Stethoscope className="w-6 h-6 text-secondary-600" />
|
||||||
<p className="text-sm text-red-700 font-medium">Primary Physician</p>
|
</div>
|
||||||
<p className="text-secondary-900 font-medium">{info.primaryPhysician}</p>
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-secondary-500 uppercase tracking-wide font-semibold mb-1">
|
||||||
|
Primary Physician
|
||||||
|
</p>
|
||||||
|
<p className="font-display text-lg text-secondary-900">{info.primaryPhysician}</p>
|
||||||
{info.physicianPhone && (
|
{info.physicianPhone && (
|
||||||
<a
|
<a
|
||||||
href={`tel:${info.physicianPhone}`}
|
href={`tel:${info.physicianPhone}`}
|
||||||
className="text-primary-600 hover:underline"
|
className="inline-flex items-center gap-2 mt-2 text-primary-600 font-medium hover:text-primary-700 hover:underline"
|
||||||
>
|
>
|
||||||
|
<Phone className="w-4 h-4" />
|
||||||
{info.physicianPhone}
|
{info.physicianPhone}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
@@ -138,38 +174,42 @@ export function EmergencyCard({ info, medications, variant = 'full' }: Emergency
|
|||||||
|
|
||||||
{/* Emergency Contacts */}
|
{/* Emergency Contacts */}
|
||||||
{(info.clinicPhone || info.emergencyPhone) && (
|
{(info.clinicPhone || info.emergencyPhone) && (
|
||||||
<div className="border-t border-red-200 pt-4 space-y-3">
|
<div className="border-t-2 border-cream-200 pt-6">
|
||||||
<p className="text-sm text-red-700 font-bold">Emergency Contacts</p>
|
<p className="text-sm text-secondary-700 font-semibold uppercase tracking-wide mb-4 flex items-center gap-2">
|
||||||
|
<Phone className="w-5 h-5 text-alert-500" />
|
||||||
|
Emergency Contacts
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{info.clinicPhone && (
|
||||||
|
<a
|
||||||
|
href={`tel:${info.clinicPhone}`}
|
||||||
|
className="flex items-center gap-4 p-4 bg-alert-50 rounded-card border border-alert-200 hover:bg-alert-100 hover:border-alert-300 hover:shadow-soft transition-all duration-300 group"
|
||||||
|
>
|
||||||
|
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-alert-500 to-alert-600 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
|
||||||
|
<Phone className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-secondary-900 text-lg">Call Clinic</p>
|
||||||
|
<p className="text-alert-600 font-medium">{info.clinicPhone}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
{info.clinicPhone && (
|
{info.emergencyPhone && (
|
||||||
<a
|
<a
|
||||||
href={`tel:${info.clinicPhone}`}
|
href={`tel:${info.emergencyPhone}`}
|
||||||
className="flex items-center gap-3 p-3 bg-white rounded-lg border border-red-200 hover:bg-red-50 transition-colors"
|
className="flex items-center gap-4 p-4 bg-cream-50 rounded-card border border-cream-200 hover:bg-cream-100 hover:border-cream-300 hover:shadow-soft transition-all duration-300 group"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-full bg-red-600 flex items-center justify-center">
|
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-secondary-500 to-secondary-600 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
|
||||||
<Phone className="w-5 h-5 text-white" />
|
<Phone className="w-7 h-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-secondary-900">Call Clinic</p>
|
<p className="font-semibold text-secondary-900 text-lg">Emergency Contact</p>
|
||||||
<p className="text-sm text-secondary-600">{info.clinicPhone}</p>
|
<p className="text-secondary-600 font-medium">{info.emergencyPhone}</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{info.emergencyPhone && (
|
|
||||||
<a
|
|
||||||
href={`tel:${info.emergencyPhone}`}
|
|
||||||
className="flex items-center gap-3 p-3 bg-white rounded-lg border border-red-200 hover:bg-red-50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="w-10 h-10 rounded-full bg-red-600 flex items-center justify-center">
|
|
||||||
<Phone className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-secondary-900">Emergency Contact</p>
|
|
||||||
<p className="text-sm text-secondary-600">{info.emergencyPhone}</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
91
src/components/labs/LabResultCard.tsx
Normal file
91
src/components/labs/LabResultCard.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { FileText } from 'lucide-react'
|
||||||
|
import { Card } from '@/components/ui'
|
||||||
|
import { MarkerRow } from './MarkerRow'
|
||||||
|
|
||||||
|
interface MarkerData {
|
||||||
|
marker: string
|
||||||
|
value: number
|
||||||
|
unit: string
|
||||||
|
refMin: number | null
|
||||||
|
refMax: number | null
|
||||||
|
flag: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LabResultData {
|
||||||
|
id: string
|
||||||
|
testDate: string
|
||||||
|
panelName: string
|
||||||
|
labName: string | null
|
||||||
|
results: MarkerData[]
|
||||||
|
notes: string | null
|
||||||
|
createdBy?: { id: string; name: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LabResultCardProps {
|
||||||
|
result: LabResultData
|
||||||
|
onEdit?: (result: LabResultData) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabResultCard({ result, onEdit }: LabResultCardProps) {
|
||||||
|
const markers = result.results || []
|
||||||
|
const flaggedCount = markers.filter((m) => m.flag).length
|
||||||
|
const criticalCount = markers.filter((m) => m.flag?.startsWith('CRITICAL')).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer hover:shadow-card-hover transition-shadow"
|
||||||
|
onClick={() => onEdit?.(result)}
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||||
|
<FileText className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-secondary-900">{result.panelName}</h3>
|
||||||
|
<p className="text-xs text-secondary-500">
|
||||||
|
{format(new Date(result.testDate), 'MMM d, yyyy')}
|
||||||
|
{result.labName && ` · ${result.labName}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Flag summary */}
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{criticalCount > 0 && (
|
||||||
|
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-red-100 text-red-700">
|
||||||
|
{criticalCount} critical
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{flaggedCount > 0 && flaggedCount !== criticalCount && (
|
||||||
|
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700">
|
||||||
|
{flaggedCount - criticalCount} flagged
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{flaggedCount === 0 && markers.length > 0 && (
|
||||||
|
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-green-100 text-green-700">
|
||||||
|
All normal
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Marker rows */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{markers.map((m, i) => (
|
||||||
|
<MarkerRow key={`${m.marker}-${i}`} marker={m} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{result.notes && (
|
||||||
|
<p className="text-xs text-secondary-500 mt-3 italic">{result.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
252
src/components/labs/LabResultForm.tsx
Normal file
252
src/components/labs/LabResultForm.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { Modal, Button, Input, Select, Textarea, showToast } from '@/components/ui'
|
||||||
|
import { LAB_PANELS, computeFlag, type PanelMarker } from '@/lib/labs/panels'
|
||||||
|
|
||||||
|
interface MarkerFormRow {
|
||||||
|
marker: string
|
||||||
|
value: string
|
||||||
|
unit: string
|
||||||
|
refMin: string
|
||||||
|
refMax: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LabResultFormProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void
|
||||||
|
workspaceId: string
|
||||||
|
initialData?: {
|
||||||
|
id?: string
|
||||||
|
testDate?: string
|
||||||
|
panelName?: string
|
||||||
|
labName?: string | null
|
||||||
|
results?: Array<{
|
||||||
|
marker: string
|
||||||
|
value: number
|
||||||
|
unit: string
|
||||||
|
refMin: number | null
|
||||||
|
refMax: number | null
|
||||||
|
flag: string | null
|
||||||
|
}>
|
||||||
|
notes?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerToRow(m: PanelMarker): MarkerFormRow {
|
||||||
|
return {
|
||||||
|
marker: m.marker,
|
||||||
|
value: '',
|
||||||
|
unit: m.unit,
|
||||||
|
refMin: m.refMin !== null ? String(m.refMin) : '',
|
||||||
|
refMax: m.refMax !== null ? String(m.refMax) : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyRow(): MarkerFormRow {
|
||||||
|
return { marker: '', value: '', unit: '', refMin: '', refMax: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelOptions = LAB_PANELS.map((p) => ({ value: p.name, label: p.name }))
|
||||||
|
|
||||||
|
export function LabResultForm({ isOpen, onClose, onSaved, workspaceId, initialData }: LabResultFormProps) {
|
||||||
|
const isEdit = !!initialData?.id
|
||||||
|
|
||||||
|
const [testDate, setTestDate] = useState(
|
||||||
|
initialData?.testDate
|
||||||
|
? new Date(initialData.testDate).toISOString().slice(0, 16)
|
||||||
|
: new Date().toISOString().slice(0, 16)
|
||||||
|
)
|
||||||
|
const [panelName, setPanelName] = useState(initialData?.panelName || LAB_PANELS[0].name)
|
||||||
|
const [labName, setLabName] = useState(initialData?.labName || '')
|
||||||
|
const [notes, setNotes] = useState(initialData?.notes || '')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<MarkerFormRow[]>(() => {
|
||||||
|
if (initialData?.results) {
|
||||||
|
return initialData.results.map((m) => ({
|
||||||
|
marker: m.marker,
|
||||||
|
value: String(m.value),
|
||||||
|
unit: m.unit,
|
||||||
|
refMin: m.refMin !== null ? String(m.refMin) : '',
|
||||||
|
refMax: m.refMax !== null ? String(m.refMax) : '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
const panel = LAB_PANELS.find((p) => p.name === panelName)
|
||||||
|
return panel?.markers.length ? panel.markers.map(markerToRow) : [emptyRow()]
|
||||||
|
})
|
||||||
|
|
||||||
|
const handlePanelChange = (name: string) => {
|
||||||
|
setPanelName(name)
|
||||||
|
const panel = LAB_PANELS.find((p) => p.name === name)
|
||||||
|
if (panel && panel.markers.length > 0) {
|
||||||
|
setRows(panel.markers.map(markerToRow))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRow = (index: number, field: keyof MarkerFormRow, value: string) => {
|
||||||
|
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addRow = () => setRows((prev) => [...prev, emptyRow()])
|
||||||
|
const removeRow = (index: number) => setRows((prev) => prev.filter((_, i) => i !== index))
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
// Validate: at least one marker with a value
|
||||||
|
const filledRows = rows.filter((r) => r.marker.trim() && r.value.trim())
|
||||||
|
if (filledRows.length === 0) {
|
||||||
|
showToast('Enter at least one marker value', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const results = filledRows.map((r) => {
|
||||||
|
const value = parseFloat(r.value)
|
||||||
|
const refMin = r.refMin ? parseFloat(r.refMin) : null
|
||||||
|
const refMax = r.refMax ? parseFloat(r.refMax) : null
|
||||||
|
const flag = computeFlag(value, refMin, refMax)
|
||||||
|
return {
|
||||||
|
marker: r.marker.trim(),
|
||||||
|
value,
|
||||||
|
unit: r.unit.trim(),
|
||||||
|
refMin,
|
||||||
|
refMax,
|
||||||
|
flag,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = isEdit
|
||||||
|
? `/api/workspaces/${workspaceId}/lab-results/${initialData!.id}`
|
||||||
|
: `/api/workspaces/${workspaceId}/lab-results`
|
||||||
|
const method = isEdit ? 'PATCH' : 'POST'
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
testDate: new Date(testDate).toISOString(),
|
||||||
|
panelName,
|
||||||
|
labName: labName.trim() || null,
|
||||||
|
results,
|
||||||
|
notes: notes.trim() || null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to save lab result')
|
||||||
|
showToast(isEdit ? 'Lab result updated' : 'Lab result saved', 'success')
|
||||||
|
onSaved()
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to save lab result', 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? 'Edit Lab Result' : 'New Lab Result'}>
|
||||||
|
<div className="space-y-4 max-h-[70vh] overflow-y-auto">
|
||||||
|
{/* Panel selector */}
|
||||||
|
{!isEdit && (
|
||||||
|
<Select
|
||||||
|
label="Panel Template"
|
||||||
|
value={panelName}
|
||||||
|
onChange={(e) => handlePanelChange(e.target.value)}
|
||||||
|
options={panelOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Test Date *"
|
||||||
|
type="datetime-local"
|
||||||
|
value={testDate}
|
||||||
|
onChange={(e) => setTestDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Lab Name"
|
||||||
|
value={labName}
|
||||||
|
onChange={(e) => setLabName(e.target.value)}
|
||||||
|
placeholder="e.g. Quest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Marker rows */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-sm font-semibold text-secondary-700">Markers</p>
|
||||||
|
<button
|
||||||
|
onClick={addRow}
|
||||||
|
className="flex items-center gap-1 text-xs font-medium text-primary-600 hover:text-primary-700"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Add Row
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<div key={i} className="bg-muted rounded-lg p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={row.marker}
|
||||||
|
onChange={(e) => updateRow(i, 'marker', e.target.value)}
|
||||||
|
placeholder="Marker name"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
{rows.length > 1 && (
|
||||||
|
<button onClick={() => removeRow(i)} className="text-secondary-400 hover:text-red-500">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
<Input
|
||||||
|
value={row.value}
|
||||||
|
onChange={(e) => updateRow(i, 'value', e.target.value)}
|
||||||
|
placeholder="Value"
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={row.unit}
|
||||||
|
onChange={(e) => updateRow(i, 'unit', e.target.value)}
|
||||||
|
placeholder="Unit"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={row.refMin}
|
||||||
|
onChange={(e) => updateRow(i, 'refMin', e.target.value)}
|
||||||
|
placeholder="Min"
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={row.refMax}
|
||||||
|
onChange={(e) => updateRow(i, 'refMax', e.target.value)}
|
||||||
|
placeholder="Max"
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Additional notes..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
|
||||||
|
<Button onClick={handleSave} fullWidth loading={saving}>
|
||||||
|
{isEdit ? 'Update' : 'Save Results'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
154
src/components/labs/LabTrendChart.tsx
Normal file
154
src/components/labs/LabTrendChart.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
|
interface TrendPoint {
|
||||||
|
date: string
|
||||||
|
value: number
|
||||||
|
unit: string
|
||||||
|
refMin: number | null
|
||||||
|
refMax: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LabTrendChartProps {
|
||||||
|
marker: string
|
||||||
|
workspaceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabTrendChart({ marker, workspaceId }: LabTrendChartProps) {
|
||||||
|
const [data, setData] = useState<TrendPoint[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!marker) return
|
||||||
|
setLoading(true)
|
||||||
|
fetch(`/api/workspaces/${workspaceId}/lab-results/trends?marker=${encodeURIComponent(marker)}`)
|
||||||
|
.then((res) => res.ok ? res.json() : null)
|
||||||
|
.then((json) => {
|
||||||
|
if (json?.trendData) setData(json.trendData)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [marker, workspaceId])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="h-48 flex items-center justify-center text-secondary-400 text-sm">
|
||||||
|
Loading trend data...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length < 2) {
|
||||||
|
return (
|
||||||
|
<div className="h-48 flex items-center justify-center text-secondary-400 text-sm">
|
||||||
|
Need at least 2 data points for a trend chart
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate chart dimensions
|
||||||
|
const chartWidth = 320
|
||||||
|
const chartHeight = 160
|
||||||
|
const padding = { top: 15, right: 15, bottom: 30, left: 50 }
|
||||||
|
const plotWidth = chartWidth - padding.left - padding.right
|
||||||
|
const plotHeight = chartHeight - padding.top - padding.bottom
|
||||||
|
|
||||||
|
// Scale calculations
|
||||||
|
const values = data.map((d) => d.value)
|
||||||
|
const refMin = data[0].refMin
|
||||||
|
const refMax = data[0].refMax
|
||||||
|
const allValues = [...values]
|
||||||
|
if (refMin !== null) allValues.push(refMin)
|
||||||
|
if (refMax !== null) allValues.push(refMax)
|
||||||
|
|
||||||
|
const dataMin = Math.min(...allValues)
|
||||||
|
const dataMax = Math.max(...allValues)
|
||||||
|
const valueRange = dataMax - dataMin || 1
|
||||||
|
const yMin = dataMin - valueRange * 0.1
|
||||||
|
const yMax = dataMax + valueRange * 0.1
|
||||||
|
const yRange = yMax - yMin
|
||||||
|
|
||||||
|
const scaleX = (i: number) => padding.left + (i / (data.length - 1)) * plotWidth
|
||||||
|
const scaleY = (v: number) => padding.top + plotHeight - ((v - yMin) / yRange) * plotHeight
|
||||||
|
|
||||||
|
// Build SVG path
|
||||||
|
const linePath = data
|
||||||
|
.map((d, i) => `${i === 0 ? 'M' : 'L'} ${scaleX(i)} ${scaleY(d.value)}`)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
// Reference range rect
|
||||||
|
const refRangeY = refMax !== null ? scaleY(refMax) : padding.top
|
||||||
|
const refRangeHeight = refMin !== null && refMax !== null
|
||||||
|
? scaleY(refMin) - scaleY(refMax)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const unit = data[0].unit
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<svg viewBox={`0 0 ${chartWidth} ${chartHeight}`} className="w-full max-w-sm mx-auto">
|
||||||
|
{/* Reference range band */}
|
||||||
|
{refMin !== null && refMax !== null && (
|
||||||
|
<rect
|
||||||
|
x={padding.left}
|
||||||
|
y={refRangeY}
|
||||||
|
width={plotWidth}
|
||||||
|
height={refRangeHeight}
|
||||||
|
fill="rgb(34 197 94 / 0.1)"
|
||||||
|
stroke="rgb(34 197 94 / 0.2)"
|
||||||
|
strokeWidth="0.5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Grid lines */}
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||||
|
const y = padding.top + frac * plotHeight
|
||||||
|
const value = yMax - frac * yRange
|
||||||
|
return (
|
||||||
|
<g key={frac}>
|
||||||
|
<line
|
||||||
|
x1={padding.left} y1={y}
|
||||||
|
x2={padding.left + plotWidth} y2={y}
|
||||||
|
stroke="rgb(0 0 0 / 0.06)" strokeWidth="0.5"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={padding.left - 5} y={y + 3}
|
||||||
|
textAnchor="end" className="text-[8px] fill-secondary-400"
|
||||||
|
>
|
||||||
|
{value.toFixed(1)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Data line */}
|
||||||
|
<path d={linePath} fill="none" stroke="rgb(59 130 246)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
|
||||||
|
{/* Data points */}
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<circle cx={scaleX(i)} cy={scaleY(d.value)} r="4" fill="white" stroke="rgb(59 130 246)" strokeWidth="2" />
|
||||||
|
{/* Date label on x-axis */}
|
||||||
|
<text
|
||||||
|
x={scaleX(i)} y={chartHeight - 5}
|
||||||
|
textAnchor="middle" className="text-[7px] fill-secondary-400"
|
||||||
|
>
|
||||||
|
{format(new Date(d.date), 'M/d')}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Unit label */}
|
||||||
|
<text
|
||||||
|
x={3} y={padding.top + plotHeight / 2}
|
||||||
|
textAnchor="middle" className="text-[7px] fill-secondary-400"
|
||||||
|
transform={`rotate(-90, 8, ${padding.top + plotHeight / 2})`}
|
||||||
|
>
|
||||||
|
{unit}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
src/components/labs/MarkerRow.tsx
Normal file
57
src/components/labs/MarkerRow.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
interface MarkerData {
|
||||||
|
marker: string
|
||||||
|
value: number
|
||||||
|
unit: string
|
||||||
|
refMin: number | null
|
||||||
|
refMax: number | null
|
||||||
|
flag: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkerRowProps {
|
||||||
|
marker: MarkerData
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLAG_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||||
|
LOW: { bg: 'bg-yellow-50', text: 'text-yellow-700', label: 'L' },
|
||||||
|
HIGH: { bg: 'bg-yellow-50', text: 'text-yellow-700', label: 'H' },
|
||||||
|
CRITICAL_LOW: { bg: 'bg-red-50', text: 'text-red-700', label: 'LL' },
|
||||||
|
CRITICAL_HIGH: { bg: 'bg-red-50', text: 'text-red-700', label: 'HH' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkerRow({ marker: m }: MarkerRowProps) {
|
||||||
|
const flagStyle = m.flag ? FLAG_STYLES[m.flag] : null
|
||||||
|
const hasRange = m.refMin !== null || m.refMax !== null
|
||||||
|
const rangeText = hasRange
|
||||||
|
? `${m.refMin ?? '—'} – ${m.refMax ?? '—'}`
|
||||||
|
: '—'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-between py-2 px-3 rounded-lg ${flagStyle?.bg || ''}`}>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className={`text-sm font-medium ${flagStyle?.text || 'text-secondary-900'}`}>
|
||||||
|
{m.marker}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`text-sm font-semibold tabular-nums ${flagStyle?.text || 'text-secondary-900'}`}>
|
||||||
|
{m.value} {m.unit}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-secondary-400 w-24 text-right tabular-nums">
|
||||||
|
{rangeText}
|
||||||
|
</span>
|
||||||
|
{flagStyle && (
|
||||||
|
<span className={`text-xs font-bold px-1.5 py-0.5 rounded ${flagStyle.bg} ${flagStyle.text}`}>
|
||||||
|
{flagStyle.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!flagStyle && hasRange && (
|
||||||
|
<span className="text-xs font-bold px-1.5 py-0.5 rounded bg-green-50 text-green-600">
|
||||||
|
N
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
src/components/medications/InteractionBanner.tsx
Normal file
34
src/components/medications/InteractionBanner.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface InteractionBannerProps {
|
||||||
|
count: number
|
||||||
|
hasMajor: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InteractionBanner({ count, hasMajor, onClick }: InteractionBannerProps) {
|
||||||
|
if (count === 0) return null
|
||||||
|
|
||||||
|
const bgColor = hasMajor ? 'bg-red-50 border-red-200' : 'bg-yellow-50 border-yellow-200'
|
||||||
|
const textColor = hasMajor ? 'text-red-700' : 'text-yellow-700'
|
||||||
|
const iconColor = hasMajor ? 'text-red-500' : 'text-yellow-500'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`w-full flex items-center gap-3 border rounded-card p-3 ${bgColor} transition-shadow hover:shadow-card-hover`}
|
||||||
|
>
|
||||||
|
<AlertTriangle className={`w-5 h-5 ${iconColor} flex-shrink-0`} />
|
||||||
|
<div className="text-left">
|
||||||
|
<p className={`text-sm font-semibold ${textColor}`}>
|
||||||
|
{count} Drug Interaction{count !== 1 ? 's' : ''} Found
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-secondary-500">
|
||||||
|
{hasMajor ? 'Includes major interactions — review with your care team' : 'Tap to review details'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
src/components/medications/InteractionCard.tsx
Normal file
94
src/components/medications/InteractionCard.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { AlertTriangle, AlertOctagon, Info, XOctagon } from 'lucide-react'
|
||||||
|
|
||||||
|
interface InteractionData {
|
||||||
|
drug1Name: string
|
||||||
|
drug2Name: string
|
||||||
|
severity: string
|
||||||
|
description: string
|
||||||
|
recommendation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InteractionCardProps {
|
||||||
|
interaction: InteractionData
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_CONFIG: Record<string, {
|
||||||
|
bg: string
|
||||||
|
border: string
|
||||||
|
text: string
|
||||||
|
badge: string
|
||||||
|
label: string
|
||||||
|
Icon: typeof AlertTriangle
|
||||||
|
}> = {
|
||||||
|
CONTRAINDICATED: {
|
||||||
|
bg: 'bg-red-50',
|
||||||
|
border: 'border-red-200',
|
||||||
|
text: 'text-red-800',
|
||||||
|
badge: 'bg-red-600 text-white',
|
||||||
|
label: 'Contraindicated',
|
||||||
|
Icon: XOctagon,
|
||||||
|
},
|
||||||
|
MAJOR: {
|
||||||
|
bg: 'bg-orange-50',
|
||||||
|
border: 'border-orange-200',
|
||||||
|
text: 'text-orange-800',
|
||||||
|
badge: 'bg-orange-500 text-white',
|
||||||
|
label: 'Major',
|
||||||
|
Icon: AlertOctagon,
|
||||||
|
},
|
||||||
|
MODERATE: {
|
||||||
|
bg: 'bg-yellow-50',
|
||||||
|
border: 'border-yellow-200',
|
||||||
|
text: 'text-yellow-800',
|
||||||
|
badge: 'bg-yellow-500 text-white',
|
||||||
|
label: 'Moderate',
|
||||||
|
Icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
MINOR: {
|
||||||
|
bg: 'bg-blue-50',
|
||||||
|
border: 'border-blue-200',
|
||||||
|
text: 'text-blue-800',
|
||||||
|
badge: 'bg-blue-500 text-white',
|
||||||
|
label: 'Minor',
|
||||||
|
Icon: Info,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InteractionCard({ interaction }: InteractionCardProps) {
|
||||||
|
const config = SEVERITY_CONFIG[interaction.severity] || SEVERITY_CONFIG.MINOR
|
||||||
|
const { Icon } = config
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-card border ${config.border} ${config.bg} p-4`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Icon className={`w-5 h-5 ${config.text} flex-shrink-0 mt-0.5`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Header with severity badge */}
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${config.badge}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drug names */}
|
||||||
|
<p className={`font-semibold text-sm ${config.text}`}>
|
||||||
|
{interaction.drug1Name} + {interaction.drug2Name}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-sm text-secondary-700 mt-1">{interaction.description}</p>
|
||||||
|
|
||||||
|
{/* Recommendation */}
|
||||||
|
<div className="mt-2 bg-white/50 rounded-lg px-3 py-2">
|
||||||
|
<p className="text-xs font-medium text-secondary-600">
|
||||||
|
<span className="font-semibold">Recommendation:</span>{' '}
|
||||||
|
{interaction.recommendation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
122
src/components/medications/InteractionCheck.tsx
Normal file
122
src/components/medications/InteractionCheck.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Shield, Loader2 } from 'lucide-react'
|
||||||
|
import { Modal, Button, showToast } from '@/components/ui'
|
||||||
|
import { InteractionCard } from './InteractionCard'
|
||||||
|
|
||||||
|
interface InteractionResult {
|
||||||
|
drug1Name: string
|
||||||
|
drug2Name: string
|
||||||
|
severity: string
|
||||||
|
description: string
|
||||||
|
recommendation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InteractionCheckProps {
|
||||||
|
workspaceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InteractionCheck({ workspaceId }: InteractionCheckProps) {
|
||||||
|
const [checking, setChecking] = useState(false)
|
||||||
|
const [results, setResults] = useState<InteractionResult[] | null>(null)
|
||||||
|
const [showResults, setShowResults] = useState(false)
|
||||||
|
const [medCount, setMedCount] = useState(0)
|
||||||
|
|
||||||
|
const handleCheck = async () => {
|
||||||
|
setChecking(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/workspaces/${workspaceId}/medications/check-interactions`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
)
|
||||||
|
if (!response.ok) throw new Error('Failed to check interactions')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setResults(data.interactions)
|
||||||
|
setMedCount(data.medicationCount)
|
||||||
|
setShowResults(true)
|
||||||
|
|
||||||
|
if (data.interactions.length === 0) {
|
||||||
|
showToast('No interactions found', 'success')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to check interactions', 'error')
|
||||||
|
} finally {
|
||||||
|
setChecking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const majorCount = results?.filter(
|
||||||
|
(r) => r.severity === 'MAJOR' || r.severity === 'CONTRAINDICATED'
|
||||||
|
).length ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Check button */}
|
||||||
|
<button
|
||||||
|
onClick={handleCheck}
|
||||||
|
disabled={checking}
|
||||||
|
className="w-full flex items-center justify-center gap-2 bg-primary-50 hover:bg-primary-100 border border-primary-200 rounded-card p-3 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{checking ? (
|
||||||
|
<Loader2 className="w-5 h-5 text-primary-600 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Shield className="w-5 h-5 text-primary-600" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-semibold text-primary-700">
|
||||||
|
{checking ? 'Checking...' : 'Check Drug Interactions'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Results Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showResults}
|
||||||
|
onClose={() => setShowResults(false)}
|
||||||
|
title="Drug Interactions"
|
||||||
|
>
|
||||||
|
<div className="space-y-4 max-h-[70vh] overflow-y-auto">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="text-sm text-secondary-600">
|
||||||
|
Checked {medCount} active medications.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results && results.length === 0 && (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<Shield className="w-12 h-12 text-green-500 mx-auto mb-3" />
|
||||||
|
<p className="font-semibold text-green-700">No Interactions Found</p>
|
||||||
|
<p className="text-sm text-secondary-500 mt-1">
|
||||||
|
No known interactions between your current medications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results && results.length > 0 && (
|
||||||
|
<>
|
||||||
|
{majorCount > 0 && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg px-3 py-2 text-sm text-red-700 font-medium">
|
||||||
|
{majorCount} major interaction{majorCount !== 1 ? 's' : ''} found — discuss with your care team
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{results.map((interaction, i) => (
|
||||||
|
<InteractionCard key={i} interaction={interaction} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button variant="secondary" onClick={() => setShowResults(false)} fullWidth>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-secondary-400 text-center">
|
||||||
|
This is a simplified check using a local database. Always consult your pharmacist or oncologist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,26 +2,27 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Button, Input, Textarea, Select, Card, showToast } from '@/components/ui'
|
import { Clock, Calendar, Repeat, Pill, Package, ChevronDown, Plus, X } from 'lucide-react'
|
||||||
|
import { Button, Input, Textarea, Select, showToast } from '@/components/ui'
|
||||||
import { useApp } from '@/app/(app)/provider'
|
import { useApp } from '@/app/(app)/provider'
|
||||||
|
|
||||||
type ScheduleType = 'FIXED_TIMES' | 'INTERVAL' | 'WEEKDAYS' | 'PRN'
|
type ScheduleType = 'FIXED_TIMES' | 'INTERVAL' | 'WEEKDAYS' | 'PRN'
|
||||||
|
|
||||||
const scheduleTypeOptions = [
|
const scheduleTypeOptions = [
|
||||||
{ value: 'FIXED_TIMES', label: 'Fixed times daily' },
|
{ value: 'FIXED_TIMES', label: 'Fixed times daily', icon: Clock, desc: 'Same times every day' },
|
||||||
{ value: 'INTERVAL', label: 'Every X hours' },
|
{ value: 'INTERVAL', label: 'Every X hours', icon: Repeat, desc: 'Regular intervals' },
|
||||||
{ value: 'WEEKDAYS', label: 'Specific days of week' },
|
{ value: 'WEEKDAYS', label: 'Specific days', icon: Calendar, desc: 'Certain days of the week' },
|
||||||
{ value: 'PRN', label: 'As needed (PRN)' },
|
{ value: 'PRN', label: 'As needed (PRN)', icon: Pill, desc: 'When you need it' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const weekdays = [
|
const weekdays = [
|
||||||
{ value: 0, label: 'Sun' },
|
{ value: 0, label: 'Sun', full: 'Sunday' },
|
||||||
{ value: 1, label: 'Mon' },
|
{ value: 1, label: 'Mon', full: 'Monday' },
|
||||||
{ value: 2, label: 'Tue' },
|
{ value: 2, label: 'Tue', full: 'Tuesday' },
|
||||||
{ value: 3, label: 'Wed' },
|
{ value: 3, label: 'Wed', full: 'Wednesday' },
|
||||||
{ value: 4, label: 'Thu' },
|
{ value: 4, label: 'Thu', full: 'Thursday' },
|
||||||
{ value: 5, label: 'Fri' },
|
{ value: 5, label: 'Fri', full: 'Friday' },
|
||||||
{ value: 6, label: 'Sat' },
|
{ value: 6, label: 'Sat', full: 'Saturday' },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface MedicationFormProps {
|
interface MedicationFormProps {
|
||||||
@@ -42,6 +43,7 @@ interface MedicationFormProps {
|
|||||||
export function MedicationForm({ initialData, isEditing = false }: MedicationFormProps) {
|
export function MedicationForm({ initialData, isEditing = false }: MedicationFormProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { currentWorkspace, refreshData } = useApp()
|
const { currentWorkspace, refreshData } = useApp()
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
const [name, setName] = useState(initialData?.name || '')
|
const [name, setName] = useState(initialData?.name || '')
|
||||||
const [instructions, setInstructions] = useState(initialData?.instructions || '')
|
const [instructions, setInstructions] = useState(initialData?.instructions || '')
|
||||||
@@ -72,9 +74,12 @@ export function MedicationForm({ initialData, isEditing = false }: MedicationFor
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset defaults if switching types and no initial data for that type
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (initialData?.scheduleType !== scheduleType) {
|
if (initialData?.scheduleType !== scheduleType) {
|
||||||
// Keep current state if user is just switching around in new mode
|
// Keep current state if user is just switching around in new mode
|
||||||
}
|
}
|
||||||
}, [scheduleType, initialData])
|
}, [scheduleType, initialData])
|
||||||
|
|
||||||
@@ -134,13 +139,11 @@ export function MedicationForm({ initialData, isEditing = false }: MedicationFor
|
|||||||
scheduleType,
|
scheduleType,
|
||||||
scheduleData: buildScheduleData(),
|
scheduleData: buildScheduleData(),
|
||||||
active: true,
|
active: true,
|
||||||
// Refill tracking
|
|
||||||
...(trackRefills && pillCount !== '' && {
|
...(trackRefills && pillCount !== '' && {
|
||||||
pillCount: Number(pillCount),
|
pillCount: Number(pillCount),
|
||||||
pillsPerDose,
|
pillsPerDose,
|
||||||
refillThreshold,
|
refillThreshold,
|
||||||
}),
|
}),
|
||||||
// Explicitly nullify if disabled during edit
|
|
||||||
...(isEditing && !trackRefills && {
|
...(isEditing && !trackRefills && {
|
||||||
pillCount: null,
|
pillCount: null,
|
||||||
pillsPerDose: null,
|
pillsPerDose: null,
|
||||||
@@ -164,197 +167,337 @@ export function MedicationForm({ initialData, isEditing = false }: MedicationFor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentScheduleOption = scheduleTypeOptions.find(opt => opt.value === scheduleType)
|
||||||
|
const ScheduleIcon = currentScheduleOption?.icon || Clock
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className={`space-y-6 transition-all duration-700 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
{/* Form Header */}
|
||||||
<Input
|
<div className="text-center mb-8">
|
||||||
label="Medication Name"
|
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||||
type="text"
|
<Pill className="w-10 h-10 text-primary-600" />
|
||||||
value={name}
|
</div>
|
||||||
onChange={(e) => setName(e.target.value)}
|
<h2 className="font-display text-display-sm text-secondary-900">
|
||||||
placeholder="e.g., Paracetamol 500mg"
|
{isEditing ? 'Edit Medication' : 'Add Medication'}
|
||||||
required
|
</h2>
|
||||||
/>
|
<p className="text-secondary-500 mt-2">
|
||||||
|
{isEditing ? 'Update your medication details' : 'Keep track of your medications'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Textarea
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
label="Instructions (optional)"
|
{/* Basic Info Section */}
|
||||||
value={instructions}
|
<div className="section-warm space-y-5">
|
||||||
onChange={(e) => setInstructions(e.target.value)}
|
<h3 className="font-display text-lg text-secondary-900 flex items-center gap-2">
|
||||||
placeholder="e.g., Take with food"
|
<span className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 text-sm font-semibold">1</span>
|
||||||
rows={2}
|
Basic Information
|
||||||
/>
|
</h3>
|
||||||
|
|
||||||
<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 className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
Days
|
Medication Name
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<input
|
||||||
{weekdays.map((day) => (
|
type="text"
|
||||||
<button
|
value={name}
|
||||||
key={day.value}
|
onChange={(e) => setName(e.target.value)}
|
||||||
type="button"
|
placeholder="e.g., Paracetamol 500mg"
|
||||||
onClick={() => toggleDay(day.value)}
|
className="input-sanctuary w-full"
|
||||||
className={`px-3 py-2 rounded-button text-sm font-medium transition-colors ${
|
required
|
||||||
selectedDays.includes(day.value)
|
/>
|
||||||
? 'bg-primary-500 text-white'
|
</div>
|
||||||
: 'bg-muted text-secondary-600 hover:bg-secondary-200'
|
|
||||||
}`}
|
<div>
|
||||||
>
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
{day.label}
|
Instructions
|
||||||
</button>
|
<span className="text-secondary-400 font-normal ml-1">(optional)</span>
|
||||||
))}
|
</label>
|
||||||
</div>
|
<textarea
|
||||||
|
value={instructions}
|
||||||
|
onChange={(e) => setInstructions(e.target.value)}
|
||||||
|
placeholder="e.g., Take with food, Avoid grapefruit..."
|
||||||
|
rows={2}
|
||||||
|
className="input-sanctuary w-full resize-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
|
||||||
label="Time"
|
|
||||||
type="time"
|
|
||||||
value={weekdayTime}
|
|
||||||
onChange={(e) => setWeekdayTime(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{scheduleType === 'PRN' && (
|
{/* Schedule Section */}
|
||||||
<Input
|
<div className="section-warm space-y-5">
|
||||||
label="Minimum hours between doses"
|
<h3 className="font-display text-lg text-secondary-900 flex items-center gap-2">
|
||||||
type="number"
|
<span className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 text-sm font-semibold">2</span>
|
||||||
min={0.5}
|
Schedule
|
||||||
max={72}
|
</h3>
|
||||||
step={0.5}
|
|
||||||
value={minHoursBetween}
|
|
||||||
onChange={(e) => setMinHoursBetween(parseFloat(e.target.value) || 4)}
|
|
||||||
helperText="Shows 'Available' when enough time has passed since last dose"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Refill Tracking (optional) */}
|
{/* Schedule Type Selector */}
|
||||||
<div className="border-t border-border pt-5">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
{scheduleTypeOptions.map((option) => {
|
||||||
|
const Icon = option.icon
|
||||||
|
const isSelected = scheduleType === option.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScheduleType(option.value as ScheduleType)}
|
||||||
|
className={`p-4 rounded-card border-2 text-left transition-all duration-300 ${
|
||||||
|
isSelected
|
||||||
|
? 'border-primary-400 bg-primary-50/50 shadow-soft'
|
||||||
|
: 'border-cream-200 bg-surface hover:border-cream-300 hover:shadow-soft'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`w-6 h-6 mb-2 ${isSelected ? 'text-primary-600' : 'text-secondary-400'}`} />
|
||||||
|
<p className={`font-semibold text-sm ${isSelected ? 'text-primary-800' : 'text-secondary-700'}`}>
|
||||||
|
{option.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-secondary-400 mt-0.5">{option.desc}</p>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schedule-specific options */}
|
||||||
|
<div className="bg-cream-50/50 rounded-card p-5 border border-cream-200/60">
|
||||||
|
{scheduleType === 'FIXED_TIMES' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block text-sm font-medium text-secondary-700">
|
||||||
|
Times to take each day
|
||||||
|
</label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{times.map((time, index) => (
|
||||||
|
<div key={index} className="flex gap-2 items-center">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => updateTime(index, e.target.value)}
|
||||||
|
className="input-sanctuary w-full pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{times.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeTime(index)}
|
||||||
|
className="w-10 h-10 rounded-button bg-cream-100 hover:bg-cream-200 flex items-center justify-center text-secondary-500 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addTime}
|
||||||
|
className="btn-secondary w-full flex items-center justify-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add another time
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scheduleType === 'INTERVAL' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
|
Every (hours)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={72}
|
||||||
|
value={intervalHours}
|
||||||
|
onChange={(e) => setIntervalHours(parseInt(e.target.value) || 1)}
|
||||||
|
className="input-sanctuary w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
|
Starting at
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={startTime}
|
||||||
|
onChange={(e) => setStartTime(e.target.value)}
|
||||||
|
className="input-sanctuary w-full pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-secondary-500">
|
||||||
|
Example: Every {intervalHours} hours starting at {startTime}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scheduleType === 'WEEKDAYS' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-3">
|
||||||
|
Which days?
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
{weekdays.map((day) => {
|
||||||
|
const isSelected = selectedDays.includes(day.value)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDay(day.value)}
|
||||||
|
className={`aspect-square rounded-button text-sm font-medium transition-all duration-200 ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary-500 text-white shadow-lg scale-105'
|
||||||
|
: 'bg-cream-100 text-secondary-600 hover:bg-cream-200'
|
||||||
|
}`}
|
||||||
|
title={day.full}
|
||||||
|
>
|
||||||
|
{day.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
|
At what time?
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={weekdayTime}
|
||||||
|
onChange={(e) => setWeekdayTime(e.target.value)}
|
||||||
|
className="input-sanctuary w-full pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scheduleType === 'PRN' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
|
Minimum hours between doses
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0.5}
|
||||||
|
max={72}
|
||||||
|
step={0.5}
|
||||||
|
value={minHoursBetween}
|
||||||
|
onChange={(e) => setMinHoursBetween(parseFloat(e.target.value) || 4)}
|
||||||
|
className="input-sanctuary w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-secondary-500">
|
||||||
|
Shows "Available" when enough time has passed since your last dose
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Refill Tracking Section */}
|
||||||
|
<div className="section-warm space-y-5">
|
||||||
|
<h3 className="font-display text-lg text-secondary-900 flex items-center gap-2">
|
||||||
|
<span className="w-8 h-8 rounded-full bg-cream-200 flex items-center justify-center text-secondary-600 text-sm font-semibold">
|
||||||
|
<Package className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
Refill Tracking
|
||||||
|
<span className="text-sm font-normal text-secondary-400 ml-auto">Optional</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="trackRefills"
|
id="trackRefills"
|
||||||
checked={trackRefills}
|
checked={trackRefills}
|
||||||
onChange={(e) => setTrackRefills(e.target.checked)}
|
onChange={(e) => setTrackRefills(e.target.checked)}
|
||||||
className="w-5 h-5 rounded border-border text-primary-600 focus:ring-primary-500"
|
className="w-5 h-5 rounded border-cream-300 text-primary-500 focus:ring-primary-400"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="trackRefills" className="text-sm font-medium text-secondary-700">
|
<label htmlFor="trackRefills" className="text-sm text-secondary-700">
|
||||||
Track pill count for refill reminders (optional)
|
Track pill count and get refill reminders
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{trackRefills && (
|
{trackRefills && (
|
||||||
<div className="space-y-4 pl-8">
|
<div className="space-y-4 pt-2 pl-8 border-l-2 border-cream-200">
|
||||||
<Input
|
<div>
|
||||||
label="Current pill count"
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
type="number"
|
Current pill count
|
||||||
min={0}
|
</label>
|
||||||
value={pillCount}
|
<input
|
||||||
onChange={(e) => setPillCount(e.target.value === '' ? '' : parseInt(e.target.value))}
|
|
||||||
placeholder="e.g., 30"
|
|
||||||
helperText="How many pills do you have now?"
|
|
||||||
/>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<Input
|
|
||||||
label="Pills per dose"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={pillsPerDose}
|
|
||||||
onChange={(e) => setPillsPerDose(parseInt(e.target.value) || 1)}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Alert when below"
|
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
value={refillThreshold}
|
value={pillCount}
|
||||||
onChange={(e) => setRefillThreshold(parseInt(e.target.value) || 7)}
|
onChange={(e) => setPillCount(e.target.value === '' ? '' : parseInt(e.target.value))}
|
||||||
helperText="pills"
|
placeholder="e.g., 30"
|
||||||
|
className="input-sanctuary w-full"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-secondary-400 mt-1.5">How many pills do you have now?</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
|
Pills per dose
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={pillsPerDose}
|
||||||
|
onChange={(e) => setPillsPerDose(parseInt(e.target.value) || 1)}
|
||||||
|
className="input-sanctuary w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
|
Alert when below
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={refillThreshold}
|
||||||
|
onChange={(e) => setRefillThreshold(parseInt(e.target.value) || 7)}
|
||||||
|
className="input-sanctuary w-full"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-secondary-400 mt-1.5">pills remaining</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
|
<div className="bg-alert-50 border border-alert-200 rounded-card p-4">
|
||||||
{error}
|
<p className="text-sm text-alert-700">{error}</p>
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
{/* Action Buttons */}
|
||||||
<Button
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
|
||||||
fullWidth
|
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
|
className="btn-secondary flex-1"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</button>
|
||||||
<Button type="submit" fullWidth loading={loading}>
|
<button
|
||||||
{isEditing ? 'Update Medication' : 'Save Medication'}
|
type="submit"
|
||||||
</Button>
|
disabled={loading}
|
||||||
|
className="btn-primary flex-1 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Saving...' : isEditing ? 'Update Medication' : 'Save Medication'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ export function NotificationPermission({ workspaceId }: NotificationPermissionPr
|
|||||||
if (registration.pushManager) {
|
if (registration.pushManager) {
|
||||||
const subscription = await registration.pushManager.getSubscription()
|
const subscription = await registration.pushManager.getSubscription()
|
||||||
setIsSubscribed(!!subscription)
|
setIsSubscribed(!!subscription)
|
||||||
|
|
||||||
|
// Auto-sync subscription to server to ensure it exists
|
||||||
|
if (subscription) {
|
||||||
|
fetch('/api/notifications/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
subscription: subscription.toJSON(),
|
||||||
|
workspaceId,
|
||||||
|
}),
|
||||||
|
}).catch(console.error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to check subscription:', err)
|
console.error('Failed to check subscription:', err)
|
||||||
|
|||||||
122
src/components/tasks/TaskCard.tsx
Normal file
122
src/components/tasks/TaskCard.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { format, isPast } from 'date-fns'
|
||||||
|
import { CheckCircle2, Circle, User } from 'lucide-react'
|
||||||
|
|
||||||
|
interface TaskData {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
category: string
|
||||||
|
priority: string
|
||||||
|
status: string
|
||||||
|
dueDate: string | null
|
||||||
|
completedAt: string | null
|
||||||
|
assignedTo?: { id: string; name: string } | null
|
||||||
|
createdBy?: { id: string; name: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskCardProps {
|
||||||
|
task: TaskData
|
||||||
|
onComplete?: (id: string) => void
|
||||||
|
onEdit?: (task: TaskData) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_BADGES: Record<string, string> = {
|
||||||
|
MEDICAL: 'bg-blue-100 text-blue-700',
|
||||||
|
ERRANDS: 'bg-purple-100 text-purple-700',
|
||||||
|
MEALS: 'bg-orange-100 text-orange-700',
|
||||||
|
EMOTIONAL: 'bg-pink-100 text-pink-700',
|
||||||
|
OTHER: 'bg-secondary-100 text-secondary-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
MEDICAL: 'Medical',
|
||||||
|
ERRANDS: 'Errands',
|
||||||
|
MEALS: 'Meals',
|
||||||
|
EMOTIONAL: 'Emotional',
|
||||||
|
OTHER: 'Other',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY_DOTS: Record<string, string> = {
|
||||||
|
URGENT: 'bg-red-500',
|
||||||
|
HIGH: 'bg-orange-500',
|
||||||
|
NORMAL: 'bg-blue-500',
|
||||||
|
LOW: 'bg-secondary-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY_BORDERS: Record<string, string> = {
|
||||||
|
URGENT: 'border-l-red-500',
|
||||||
|
HIGH: 'border-l-orange-500',
|
||||||
|
NORMAL: 'border-l-blue-500',
|
||||||
|
LOW: 'border-l-secondary-300',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskCard({ task, onComplete, onEdit }: TaskCardProps) {
|
||||||
|
const isDone = task.status === 'DONE'
|
||||||
|
const isOverdue = task.dueDate && !isDone && isPast(new Date(task.dueDate))
|
||||||
|
const priorityDot = PRIORITY_DOTS[task.priority] || PRIORITY_DOTS.NORMAL
|
||||||
|
const priorityBorder = PRIORITY_BORDERS[task.priority] || PRIORITY_BORDERS.NORMAL
|
||||||
|
const categoryBadge = CATEGORY_BADGES[task.category] || CATEGORY_BADGES.OTHER
|
||||||
|
const categoryLabel = CATEGORY_LABELS[task.category] || task.category
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-surface rounded-lg border border-border border-l-4 ${priorityBorder} p-4 cursor-pointer hover:shadow-card-hover transition-shadow`}
|
||||||
|
onClick={() => onEdit?.(task)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Checkbox */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!isDone) onComplete?.(task.id)
|
||||||
|
}}
|
||||||
|
className="flex-shrink-0 mt-0.5"
|
||||||
|
aria-label={isDone ? 'Completed' : 'Mark as done'}
|
||||||
|
>
|
||||||
|
{isDone ? (
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Circle className="w-6 h-6 text-secondary-300" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className={`font-semibold truncate ${isDone ? 'line-through text-secondary-400' : 'text-secondary-900'}`}>
|
||||||
|
{task.title}
|
||||||
|
</h3>
|
||||||
|
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${priorityDot}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${categoryBadge}`}>
|
||||||
|
{categoryLabel}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{task.dueDate && (
|
||||||
|
<span className={`text-xs ${isOverdue ? 'text-red-600 font-semibold' : 'text-secondary-500'}`}>
|
||||||
|
{isOverdue ? 'Overdue: ' : 'Due: '}
|
||||||
|
{format(new Date(task.dueDate), 'MMM d')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignee */}
|
||||||
|
{task.assignedTo && (
|
||||||
|
<div className="flex items-center gap-1.5 mt-2">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
|
<span className="text-xs font-medium text-primary-700">
|
||||||
|
{task.assignedTo.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-secondary-500">{task.assignedTo.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/components/tasks/TaskFilters.tsx
Normal file
32
src/components/tasks/TaskFilters.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
const FILTERS = [
|
||||||
|
{ value: 'mine', label: 'My Tasks' },
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'done', label: 'Done' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface TaskFiltersProps {
|
||||||
|
filter: string
|
||||||
|
onFilterChange: (filter: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskFilters({ filter, onFilterChange }: TaskFiltersProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.value}
|
||||||
|
onClick={() => onFilterChange(f.value)}
|
||||||
|
className={`flex-shrink-0 px-4 py-2 rounded-full text-sm font-medium transition-all min-h-touch ${
|
||||||
|
filter === f.value
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-secondary-100 text-secondary-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
204
src/components/tasks/TaskForm.tsx
Normal file
204
src/components/tasks/TaskForm.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Modal, Button, Input, Select, Textarea, showToast } from '@/components/ui'
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ value: 'MEDICAL', label: 'Medical' },
|
||||||
|
{ value: 'ERRANDS', label: 'Errands' },
|
||||||
|
{ value: 'MEALS', label: 'Meals' },
|
||||||
|
{ value: 'EMOTIONAL', label: 'Emotional Support' },
|
||||||
|
{ value: 'OTHER', label: 'Other' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PRIORITIES = [
|
||||||
|
{ value: 'URGENT', label: 'Urgent' },
|
||||||
|
{ value: 'HIGH', label: 'High' },
|
||||||
|
{ value: 'NORMAL', label: 'Normal' },
|
||||||
|
{ value: 'LOW', label: 'Low' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUSES = [
|
||||||
|
{ value: 'TODO', label: 'To Do' },
|
||||||
|
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||||
|
{ value: 'DONE', label: 'Done' },
|
||||||
|
{ value: 'CANCELLED', label: 'Cancelled' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const QUICK_TEMPLATES = [
|
||||||
|
{ title: 'Pick up prescription', category: 'ERRANDS' },
|
||||||
|
{ title: 'Drive to appointment', category: 'ERRANDS' },
|
||||||
|
{ title: 'Prepare meals', category: 'MEALS' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface TaskFormData {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
priority: string
|
||||||
|
status: string
|
||||||
|
assignedToId: string
|
||||||
|
dueDate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskFormProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void
|
||||||
|
workspaceId: string
|
||||||
|
members?: Array<{ id: string; name: string }>
|
||||||
|
initialData?: Partial<TaskFormData> & { id?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskForm({ isOpen, onClose, onSaved, workspaceId, members = [], initialData }: TaskFormProps) {
|
||||||
|
const isEdit = !!initialData?.id
|
||||||
|
const [form, setForm] = useState<TaskFormData>({
|
||||||
|
title: initialData?.title || '',
|
||||||
|
description: initialData?.description || '',
|
||||||
|
category: initialData?.category || 'OTHER',
|
||||||
|
priority: initialData?.priority || 'NORMAL',
|
||||||
|
status: initialData?.status || 'TODO',
|
||||||
|
assignedToId: initialData?.assignedToId || '',
|
||||||
|
dueDate: initialData?.dueDate
|
||||||
|
? new Date(initialData.dueDate).toISOString().slice(0, 16)
|
||||||
|
: '',
|
||||||
|
})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const handleQuickTemplate = (template: { title: string; category: string }) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
title: template.title,
|
||||||
|
category: template.category,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.title.trim()) {
|
||||||
|
showToast('Title is required', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const url = isEdit
|
||||||
|
? `/api/workspaces/${workspaceId}/tasks/${initialData!.id}`
|
||||||
|
: `/api/workspaces/${workspaceId}/tasks`
|
||||||
|
const method = isEdit ? 'PATCH' : 'POST'
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
title: form.title.trim(),
|
||||||
|
description: form.description.trim() || null,
|
||||||
|
category: form.category,
|
||||||
|
priority: form.priority,
|
||||||
|
assignedToId: form.assignedToId || null,
|
||||||
|
dueDate: form.dueDate ? new Date(form.dueDate).toISOString() : null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
payload.status = form.status
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to save task')
|
||||||
|
showToast(isEdit ? 'Task updated' : 'Task created', 'success')
|
||||||
|
onSaved()
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to save task', 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = (field: keyof TaskFormData, value: string) =>
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }))
|
||||||
|
|
||||||
|
const assigneeOptions = [
|
||||||
|
{ value: '', label: 'Unassigned' },
|
||||||
|
...members.map((m) => ({ value: m.id, label: m.name })),
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? 'Edit Task' : 'New Task'}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Quick templates (only for new tasks) */}
|
||||||
|
{!isEdit && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-secondary-500 mb-2">Quick Add</p>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{QUICK_TEMPLATES.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.title}
|
||||||
|
onClick={() => handleQuickTemplate(t)}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium rounded-full bg-primary-50 text-primary-700 hover:bg-primary-100 transition-colors"
|
||||||
|
>
|
||||||
|
{t.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Title *"
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => update('title', e.target.value)}
|
||||||
|
placeholder="What needs to be done?"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => update('description', e.target.value)}
|
||||||
|
placeholder="Additional details..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Select
|
||||||
|
label="Category"
|
||||||
|
value={form.category}
|
||||||
|
onChange={(e) => update('category', e.target.value)}
|
||||||
|
options={CATEGORIES}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Priority"
|
||||||
|
value={form.priority}
|
||||||
|
onChange={(e) => update('priority', e.target.value)}
|
||||||
|
options={PRIORITIES}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
label="Assign To"
|
||||||
|
value={form.assignedToId}
|
||||||
|
onChange={(e) => update('assignedToId', e.target.value)}
|
||||||
|
options={assigneeOptions}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Due Date"
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.dueDate}
|
||||||
|
onChange={(e) => update('dueDate', e.target.value)}
|
||||||
|
/>
|
||||||
|
{isEdit && (
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
value={form.status}
|
||||||
|
onChange={(e) => update('status', e.target.value)}
|
||||||
|
options={STATUSES}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
|
||||||
|
<Button onClick={handleSave} fullWidth loading={saving}>
|
||||||
|
{isEdit ? 'Update' : 'Create Task'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
src/components/temperature/FeverAlert.tsx
Normal file
54
src/components/temperature/FeverAlert.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { AlertTriangle, Phone } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui'
|
||||||
|
|
||||||
|
interface FeverAlertProps {
|
||||||
|
tempCelsius: number
|
||||||
|
clinicPhone?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeverAlert({ tempCelsius, clinicPhone }: FeverAlertProps) {
|
||||||
|
if (tempCelsius < 38.0) return null
|
||||||
|
|
||||||
|
const isCritical = tempCelsius >= 38.5
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-card p-4 border-2 ${
|
||||||
|
isCritical
|
||||||
|
? 'bg-red-50 border-red-300'
|
||||||
|
: 'bg-orange-50 border-orange-300'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className={`w-6 h-6 flex-shrink-0 mt-0.5 ${
|
||||||
|
isCritical ? 'text-red-600' : 'text-orange-600'
|
||||||
|
}`} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className={`font-bold text-lg ${
|
||||||
|
isCritical ? 'text-red-800' : 'text-orange-800'
|
||||||
|
}`}>
|
||||||
|
{isCritical ? 'HIGH FEVER DETECTED' : 'FEVER DETECTED'}
|
||||||
|
</h3>
|
||||||
|
<p className={`text-sm mt-1 ${
|
||||||
|
isCritical ? 'text-red-700' : 'text-orange-700'
|
||||||
|
}`}>
|
||||||
|
{tempCelsius.toFixed(1)}°C — {isCritical
|
||||||
|
? 'Contact your care team immediately.'
|
||||||
|
: 'Monitor closely and contact your clinic if it persists.'}
|
||||||
|
</p>
|
||||||
|
{clinicPhone && (
|
||||||
|
<Button
|
||||||
|
variant={isCritical ? 'danger' : 'primary'}
|
||||||
|
size="sm"
|
||||||
|
className="mt-3"
|
||||||
|
onClick={() => window.location.href = `tel:${clinicPhone}`}
|
||||||
|
>
|
||||||
|
<Phone className="w-4 h-4 mr-2" />
|
||||||
|
Call Clinic
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
src/components/temperature/TempCard.tsx
Normal file
57
src/components/temperature/TempCard.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { Thermometer } from 'lucide-react'
|
||||||
|
|
||||||
|
interface TempReading {
|
||||||
|
id: string
|
||||||
|
tempCelsius: number
|
||||||
|
method: string | null
|
||||||
|
notes: string | null
|
||||||
|
recordedAt: string
|
||||||
|
createdBy?: { id: string; name: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TempCardProps {
|
||||||
|
reading: TempReading
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTempColor(temp: number): string {
|
||||||
|
if (temp >= 38.5) return 'text-red-600 bg-red-50 border-red-200'
|
||||||
|
if (temp >= 38.0) return 'text-orange-600 bg-orange-50 border-orange-200'
|
||||||
|
if (temp >= 37.5) return 'text-yellow-600 bg-yellow-50 border-yellow-200'
|
||||||
|
return 'text-green-600 bg-green-50 border-green-200'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TempCard({ reading }: TempCardProps) {
|
||||||
|
const colorClass = getTempColor(reading.tempCelsius)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-surface rounded-lg border border-border p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`w-12 h-12 rounded-full flex items-center justify-center border ${colorClass}`}>
|
||||||
|
<Thermometer className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-xl font-bold ${reading.tempCelsius >= 38.0 ? 'text-red-600' : 'text-secondary-900'}`}>
|
||||||
|
{reading.tempCelsius.toFixed(1)}°C
|
||||||
|
</span>
|
||||||
|
{reading.method && (
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-600 capitalize">
|
||||||
|
{reading.method}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-secondary-500 mt-0.5">
|
||||||
|
{format(new Date(reading.recordedAt), "EEEE, MMM d 'at' h:mm a")}
|
||||||
|
{reading.createdBy && ` • ${reading.createdBy.name}`}
|
||||||
|
</p>
|
||||||
|
{reading.notes && (
|
||||||
|
<p className="text-sm text-secondary-600 mt-2">{reading.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
src/components/temperature/TempChart.tsx
Normal file
84
src/components/temperature/TempChart.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { format, subDays, startOfDay, endOfDay } from 'date-fns'
|
||||||
|
|
||||||
|
interface TempReading {
|
||||||
|
tempCelsius: number
|
||||||
|
recordedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TempChartProps {
|
||||||
|
readings: TempReading[]
|
||||||
|
days?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TempChart({ readings, days = 7 }: TempChartProps) {
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
const now = new Date()
|
||||||
|
const result = []
|
||||||
|
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
const date = subDays(now, i)
|
||||||
|
const dayStart = startOfDay(date)
|
||||||
|
const dayEnd = endOfDay(date)
|
||||||
|
|
||||||
|
const dayReadings = readings.filter((r) => {
|
||||||
|
const d = new Date(r.recordedAt)
|
||||||
|
return d >= dayStart && d <= dayEnd
|
||||||
|
})
|
||||||
|
|
||||||
|
const avg = dayReadings.length > 0
|
||||||
|
? dayReadings.reduce((sum, r) => sum + r.tempCelsius, 0) / dayReadings.length
|
||||||
|
: null
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
label: format(date, 'EEE'),
|
||||||
|
date: format(date, 'MMM d'),
|
||||||
|
avg,
|
||||||
|
count: dayReadings.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}, [readings, days])
|
||||||
|
|
||||||
|
const maxTemp = 40
|
||||||
|
const minTemp = 35
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-2">
|
||||||
|
<div className="flex items-end gap-2 h-32">
|
||||||
|
{chartData.map((day, i) => {
|
||||||
|
const heightPercent = day.avg
|
||||||
|
? Math.max(5, ((day.avg - minTemp) / (maxTemp - minTemp)) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const barColor = day.avg
|
||||||
|
? day.avg >= 38.5 ? 'bg-red-400' : day.avg >= 38.0 ? 'bg-orange-400' : day.avg >= 37.5 ? 'bg-yellow-400' : 'bg-primary-400'
|
||||||
|
: 'bg-secondary-100'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||||
|
<span className="text-xs font-medium text-secondary-600">
|
||||||
|
{day.avg ? `${day.avg.toFixed(1)}°` : '—'}
|
||||||
|
</span>
|
||||||
|
<div className="w-full flex items-end" style={{ height: '80px' }}>
|
||||||
|
<div
|
||||||
|
className={`w-full rounded-t-md transition-all ${barColor}`}
|
||||||
|
style={{ height: `${heightPercent}%`, minHeight: day.avg ? '4px' : '0' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-secondary-500">{day.label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Fever threshold line label */}
|
||||||
|
<div className="flex items-center gap-2 mt-3 text-xs text-secondary-400">
|
||||||
|
<div className="w-3 h-0.5 bg-red-300" />
|
||||||
|
<span>38.0°C fever threshold</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
src/components/temperature/TempQuickLog.tsx
Normal file
114
src/components/temperature/TempQuickLog.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Thermometer } from 'lucide-react'
|
||||||
|
import { Button, showToast } from '@/components/ui'
|
||||||
|
|
||||||
|
const METHODS = [
|
||||||
|
{ value: 'oral', label: 'Oral' },
|
||||||
|
{ value: 'forehead', label: 'Forehead' },
|
||||||
|
{ value: 'ear', label: 'Ear' },
|
||||||
|
{ value: 'armpit', label: 'Armpit' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface TempQuickLogProps {
|
||||||
|
workspaceId: string
|
||||||
|
onLogged?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TempQuickLog({ workspaceId, onLogged }: TempQuickLogProps) {
|
||||||
|
const [temp, setTemp] = useState('')
|
||||||
|
const [method, setMethod] = useState<string | null>(null)
|
||||||
|
const [notes, setNotes] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const tempValue = parseFloat(temp)
|
||||||
|
if (isNaN(tempValue) || tempValue < 30 || tempValue > 45) {
|
||||||
|
showToast('Enter a valid temperature (30-45°C)', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/workspaces/${workspaceId}/temperature`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tempCelsius: tempValue,
|
||||||
|
method: method || null,
|
||||||
|
notes: notes.trim() || null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Failed to log temperature')
|
||||||
|
showToast('Temperature logged', 'success')
|
||||||
|
setTemp('')
|
||||||
|
setMethod(null)
|
||||||
|
setNotes('')
|
||||||
|
onLogged?.()
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to log temperature', 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Temperature Input */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">Temperature</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={temp}
|
||||||
|
onChange={(e) => setTemp(e.target.value)}
|
||||||
|
placeholder="36.5"
|
||||||
|
className="w-full px-4 py-4 text-3xl font-bold text-center border border-border rounded-card focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-xl text-secondary-400">°C</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Method Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">Method</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{METHODS.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMethod(method === m.value ? null : m.value)}
|
||||||
|
className={`py-2 px-3 rounded-button text-sm font-medium transition-all border ${
|
||||||
|
method === m.value
|
||||||
|
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||||
|
: 'border-border text-secondary-600 hover:border-secondary-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">Notes (optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Any symptoms, time of day..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<Button onClick={handleSubmit} fullWidth loading={saving}>
|
||||||
|
<Thermometer className="w-5 h-5 mr-2" />
|
||||||
|
Log Temperature
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
131
src/components/timeline/MilestoneCard.tsx
Normal file
131
src/components/timeline/MilestoneCard.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { Check, ChevronDown } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface MilestoneData {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
plannedDate: string
|
||||||
|
actualDate: string | null
|
||||||
|
status: string
|
||||||
|
notes: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MilestoneCardProps {
|
||||||
|
milestone: MilestoneData
|
||||||
|
onEdit?: (milestone: MilestoneData) => void
|
||||||
|
onStatusChange?: (id: string, status: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
COMPLETED: 'bg-green-500',
|
||||||
|
SCHEDULED: 'bg-blue-500',
|
||||||
|
DELAYED: 'bg-orange-500',
|
||||||
|
CANCELLED: 'bg-secondary-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_BADGES: Record<string, string> = {
|
||||||
|
CHEMO_CYCLE: 'bg-blue-100 text-blue-700',
|
||||||
|
SURGERY: 'bg-orange-100 text-orange-700',
|
||||||
|
RADIATION: 'bg-purple-100 text-purple-700',
|
||||||
|
SCAN: 'bg-green-100 text-green-700',
|
||||||
|
CONSULTATION: 'bg-secondary-100 text-secondary-700',
|
||||||
|
OTHER: 'bg-secondary-100 text-secondary-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
CHEMO_CYCLE: 'Chemo Cycle',
|
||||||
|
SURGERY: 'Surgery',
|
||||||
|
RADIATION: 'Radiation',
|
||||||
|
SCAN: 'Scan',
|
||||||
|
CONSULTATION: 'Consultation',
|
||||||
|
OTHER: 'Other',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: 'SCHEDULED', label: 'Scheduled' },
|
||||||
|
{ value: 'COMPLETED', label: 'Completed' },
|
||||||
|
{ value: 'DELAYED', label: 'Delayed' },
|
||||||
|
{ value: 'CANCELLED', label: 'Cancelled' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function MilestoneCard({ milestone, onEdit, onStatusChange }: MilestoneCardProps) {
|
||||||
|
const [showStatusMenu, setShowStatusMenu] = useState(false)
|
||||||
|
const statusColor = STATUS_COLORS[milestone.status] || STATUS_COLORS.SCHEDULED
|
||||||
|
const typeBadge = TYPE_BADGES[milestone.type] || TYPE_BADGES.OTHER
|
||||||
|
const typeLabel = TYPE_LABELS[milestone.type] || milestone.type
|
||||||
|
const dateStr = format(new Date(milestone.plannedDate), 'MMM d, yyyy')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-surface rounded-lg border border-border p-4 cursor-pointer hover:shadow-card-hover transition-shadow"
|
||||||
|
onClick={() => onEdit?.(milestone)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Status indicator */}
|
||||||
|
<div className="flex-shrink-0 mt-1">
|
||||||
|
{milestone.status === 'COMPLETED' ? (
|
||||||
|
<div className="w-6 h-6 rounded-full bg-green-500 flex items-center justify-center">
|
||||||
|
<Check className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`w-6 h-6 rounded-full ${statusColor} opacity-60`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-semibold text-secondary-900 truncate">{milestone.title}</h3>
|
||||||
|
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${typeBadge}`}>
|
||||||
|
{typeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-secondary-500">{dateStr}</p>
|
||||||
|
{milestone.notes && (
|
||||||
|
<p className="text-sm text-secondary-500 mt-1 line-clamp-2">{milestone.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick status change */}
|
||||||
|
{onStatusChange && (
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowStatusMenu(!showStatusMenu)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-full hover:bg-muted transition-colors"
|
||||||
|
aria-label="Change status"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-4 h-4 text-secondary-400" />
|
||||||
|
</button>
|
||||||
|
{showStatusMenu && (
|
||||||
|
<div className="absolute right-0 top-8 z-10 bg-surface border border-border rounded-lg shadow-lg py-1 min-w-[140px]">
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onStatusChange(milestone.id, opt.value)
|
||||||
|
setShowStatusMenu(false)
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-3 py-2 text-sm hover:bg-muted transition-colors ${
|
||||||
|
milestone.status === opt.value ? 'font-semibold text-primary-600' : 'text-secondary-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
src/components/timeline/MilestoneForm.tsx
Normal file
148
src/components/timeline/MilestoneForm.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Modal, Button, Input, Select, showToast } from '@/components/ui'
|
||||||
|
import { Textarea } from '@/components/ui/input'
|
||||||
|
|
||||||
|
const TYPES = [
|
||||||
|
{ value: 'CHEMO_CYCLE', label: 'Chemo Cycle' },
|
||||||
|
{ value: 'SURGERY', label: 'Surgery' },
|
||||||
|
{ value: 'RADIATION', label: 'Radiation' },
|
||||||
|
{ value: 'SCAN', label: 'Scan' },
|
||||||
|
{ value: 'CONSULTATION', label: 'Consultation' },
|
||||||
|
{ value: 'OTHER', label: 'Other' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUSES = [
|
||||||
|
{ value: 'SCHEDULED', label: 'Scheduled' },
|
||||||
|
{ value: 'COMPLETED', label: 'Completed' },
|
||||||
|
{ value: 'DELAYED', label: 'Delayed' },
|
||||||
|
{ value: 'CANCELLED', label: 'Cancelled' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface MilestoneFormData {
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
plannedDate: string
|
||||||
|
status: string
|
||||||
|
description: string
|
||||||
|
notes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MilestoneFormProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void
|
||||||
|
workspaceId: string
|
||||||
|
initialData?: Partial<MilestoneFormData> & { id?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MilestoneForm({ isOpen, onClose, onSaved, workspaceId, initialData }: MilestoneFormProps) {
|
||||||
|
const isEdit = !!initialData?.id
|
||||||
|
const [form, setForm] = useState<MilestoneFormData>({
|
||||||
|
title: initialData?.title || '',
|
||||||
|
type: initialData?.type || 'CHEMO_CYCLE',
|
||||||
|
plannedDate: initialData?.plannedDate
|
||||||
|
? new Date(initialData.plannedDate).toISOString().slice(0, 16)
|
||||||
|
: '',
|
||||||
|
status: initialData?.status || 'SCHEDULED',
|
||||||
|
description: initialData?.description || '',
|
||||||
|
notes: initialData?.notes || '',
|
||||||
|
})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.title.trim()) {
|
||||||
|
showToast('Title is required', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.plannedDate) {
|
||||||
|
showToast('Planned date is required', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const url = isEdit
|
||||||
|
? `/api/workspaces/${workspaceId}/milestones/${initialData!.id}`
|
||||||
|
: `/api/workspaces/${workspaceId}/milestones`
|
||||||
|
const method = isEdit ? 'PATCH' : 'POST'
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: form.title.trim(),
|
||||||
|
type: form.type,
|
||||||
|
plannedDate: new Date(form.plannedDate).toISOString(),
|
||||||
|
status: form.status,
|
||||||
|
description: form.description.trim() || null,
|
||||||
|
notes: form.notes.trim() || null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to save milestone')
|
||||||
|
showToast(isEdit ? 'Milestone updated' : 'Milestone added', 'success')
|
||||||
|
onSaved()
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to save milestone', 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = (field: keyof MilestoneFormData, value: string) =>
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? 'Edit Milestone' : 'Add Milestone'}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Title *"
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => update('title', e.target.value)}
|
||||||
|
placeholder="Chemo Cycle 3"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Type"
|
||||||
|
value={form.type}
|
||||||
|
onChange={(e) => update('type', e.target.value)}
|
||||||
|
options={TYPES}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Planned Date *"
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.plannedDate}
|
||||||
|
onChange={(e) => update('plannedDate', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
value={form.status}
|
||||||
|
onChange={(e) => update('status', e.target.value)}
|
||||||
|
options={STATUSES}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => update('description', e.target.value)}
|
||||||
|
placeholder="Details about this milestone..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Notes"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => update('notes', e.target.value)}
|
||||||
|
placeholder="Additional notes..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
|
||||||
|
<Button onClick={handleSave} fullWidth loading={saving}>
|
||||||
|
{isEdit ? 'Update' : 'Add Milestone'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
src/components/timeline/ProgressBar.tsx
Normal file
28
src/components/timeline/ProgressBar.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
milestones: Array<{ status: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBar({ milestones }: ProgressBarProps) {
|
||||||
|
const active = milestones.filter((m) => m.status !== 'CANCELLED')
|
||||||
|
const completed = active.filter((m) => m.status === 'COMPLETED')
|
||||||
|
const total = active.length
|
||||||
|
const percent = total > 0 ? Math.round((completed.length / total) * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-surface rounded-lg border border-border p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium text-secondary-700">
|
||||||
|
Cycle {completed.length} of {total} — {percent}% Complete
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-3 bg-secondary-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary-500 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
src/components/timeline/TimelineView.tsx
Normal file
67
src/components/timeline/TimelineView.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { MilestoneCard } from './MilestoneCard'
|
||||||
|
|
||||||
|
interface MilestoneData {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
plannedDate: string
|
||||||
|
actualDate: string | null
|
||||||
|
status: string
|
||||||
|
notes: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineViewProps {
|
||||||
|
milestones: MilestoneData[]
|
||||||
|
onEdit?: (milestone: MilestoneData) => void
|
||||||
|
onStatusChange?: (id: string, status: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_DOT_COLORS: Record<string, string> = {
|
||||||
|
COMPLETED: 'bg-green-500',
|
||||||
|
SCHEDULED: 'bg-blue-500',
|
||||||
|
DELAYED: 'bg-orange-500',
|
||||||
|
CANCELLED: 'bg-secondary-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimelineView({ milestones, onEdit, onStatusChange }: TimelineViewProps) {
|
||||||
|
const sorted = [...milestones].sort(
|
||||||
|
(a, b) => new Date(a.plannedDate).getTime() - new Date(b.plannedDate).getTime()
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Vertical line */}
|
||||||
|
<div className="absolute left-[19px] top-0 bottom-0 w-0.5 bg-border" />
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{sorted.map((milestone) => {
|
||||||
|
const dotColor = STATUS_DOT_COLORS[milestone.status] || STATUS_DOT_COLORS.SCHEDULED
|
||||||
|
const dateStr = format(new Date(milestone.plannedDate), 'MMM d, yyyy')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={milestone.id} className="relative flex gap-4">
|
||||||
|
{/* Date + dot */}
|
||||||
|
<div className="flex-shrink-0 w-10 flex flex-col items-center">
|
||||||
|
<div className={`w-4 h-4 rounded-full ${dotColor} border-2 border-surface z-10`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div className="flex-1 -mt-1">
|
||||||
|
<p className="text-xs text-secondary-400 mb-1">{dateStr}</p>
|
||||||
|
<MilestoneCard
|
||||||
|
milestone={milestone}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onStatusChange={onStatusChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/components/weight/WeightAlert.tsx
Normal file
31
src/components/weight/WeightAlert.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface WeightAlertProps {
|
||||||
|
currentKg: number
|
||||||
|
previousKg: number
|
||||||
|
timeframeHours: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeightAlert({ currentKg, previousKg, timeframeHours }: WeightAlertProps) {
|
||||||
|
const diff = Math.abs(currentKg - previousKg)
|
||||||
|
if (diff < 2) return null
|
||||||
|
|
||||||
|
const direction = currentKg > previousKg ? 'gained' : 'lost'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-card p-4 border-2 bg-orange-50 border-orange-300">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-orange-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-orange-800">Rapid Weight Change</h3>
|
||||||
|
<p className="text-sm text-orange-700 mt-1">
|
||||||
|
{direction} {diff.toFixed(1)} kg in the last {timeframeHours < 24 ? `${timeframeHours} hours` : `${Math.round(timeframeHours / 24)} days`}.
|
||||||
|
Rapid changes may indicate fluid retention or other concerns — consider contacting your care team.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
src/components/weight/WeightCard.tsx
Normal file
52
src/components/weight/WeightCard.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { Scale } from 'lucide-react'
|
||||||
|
|
||||||
|
interface WeightReading {
|
||||||
|
id: string
|
||||||
|
weightKg: number
|
||||||
|
notes: string | null
|
||||||
|
recordedAt: string
|
||||||
|
createdBy?: { id: string; name: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeightCardProps {
|
||||||
|
reading: WeightReading
|
||||||
|
previousKg?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeightCard({ reading, previousKg }: WeightCardProps) {
|
||||||
|
const diff = previousKg != null ? reading.weightKg - previousKg : null
|
||||||
|
const lbs = (reading.weightKg * 2.20462).toFixed(1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-surface rounded-lg border border-border p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-full flex items-center justify-center bg-primary-50 border border-primary-200 text-primary-600">
|
||||||
|
<Scale className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl font-bold text-secondary-900">
|
||||||
|
{reading.weightKg.toFixed(1)} kg
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-secondary-400">({lbs} lbs)</span>
|
||||||
|
{diff !== null && diff !== 0 && (
|
||||||
|
<span className={`text-sm font-medium ${diff > 0 ? 'text-orange-600' : 'text-green-600'}`}>
|
||||||
|
{diff > 0 ? '+' : ''}{diff.toFixed(1)} kg
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-secondary-500 mt-0.5">
|
||||||
|
{format(new Date(reading.recordedAt), "EEEE, MMM d 'at' h:mm a")}
|
||||||
|
{reading.createdBy && ` • ${reading.createdBy.name}`}
|
||||||
|
</p>
|
||||||
|
{reading.notes && (
|
||||||
|
<p className="text-sm text-secondary-600 mt-2">{reading.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
src/components/weight/WeightChart.tsx
Normal file
91
src/components/weight/WeightChart.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { format, subDays, startOfDay, endOfDay } from 'date-fns'
|
||||||
|
|
||||||
|
interface WeightReading {
|
||||||
|
weightKg: number
|
||||||
|
recordedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeightChartProps {
|
||||||
|
readings: WeightReading[]
|
||||||
|
days?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeightChart({ readings, days = 30 }: WeightChartProps) {
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
const now = new Date()
|
||||||
|
const points: { date: string; label: string; weight: number | null }[] = []
|
||||||
|
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
const date = subDays(now, i)
|
||||||
|
const dayStart = startOfDay(date)
|
||||||
|
const dayEnd = endOfDay(date)
|
||||||
|
|
||||||
|
const dayReadings = readings.filter((r) => {
|
||||||
|
const d = new Date(r.recordedAt)
|
||||||
|
return d >= dayStart && d <= dayEnd
|
||||||
|
})
|
||||||
|
|
||||||
|
const avg = dayReadings.length > 0
|
||||||
|
? dayReadings.reduce((sum, r) => sum + r.weightKg, 0) / dayReadings.length
|
||||||
|
: null
|
||||||
|
|
||||||
|
points.push({ date: format(date, 'MMM d'), label: format(date, 'd'), weight: avg })
|
||||||
|
}
|
||||||
|
|
||||||
|
return points
|
||||||
|
}, [readings, days])
|
||||||
|
|
||||||
|
const validPoints = chartData.filter((p) => p.weight !== null)
|
||||||
|
if (validPoints.length < 2) {
|
||||||
|
return (
|
||||||
|
<div className="py-8 text-center text-sm text-secondary-400">
|
||||||
|
Need at least 2 readings to show trend
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const weights = validPoints.map((p) => p.weight!)
|
||||||
|
const minW = Math.min(...weights) - 1
|
||||||
|
const maxW = Math.max(...weights) + 1
|
||||||
|
const range = maxW - minW || 1
|
||||||
|
|
||||||
|
const width = 300
|
||||||
|
const height = 120
|
||||||
|
const padding = { top: 10, right: 10, bottom: 20, left: 10 }
|
||||||
|
const chartW = width - padding.left - padding.right
|
||||||
|
const chartH = height - padding.top - padding.bottom
|
||||||
|
|
||||||
|
// Build SVG path from valid points
|
||||||
|
let pathPoints: { x: number; y: number; weight: number }[] = []
|
||||||
|
validPoints.forEach((p) => {
|
||||||
|
const idx = chartData.indexOf(p)
|
||||||
|
const x = padding.left + (idx / (chartData.length - 1)) * chartW
|
||||||
|
const y = padding.top + chartH - ((p.weight! - minW) / range) * chartH
|
||||||
|
pathPoints.push({ x, y, weight: p.weight! })
|
||||||
|
})
|
||||||
|
|
||||||
|
const pathD = pathPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-2">
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" preserveAspectRatio="xMidYMid meet">
|
||||||
|
{/* Line */}
|
||||||
|
<path d={pathD} fill="none" stroke="currentColor" className="text-primary-500" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
{/* Points */}
|
||||||
|
{pathPoints.map((p, i) => (
|
||||||
|
<circle key={i} cx={p.x} cy={p.y} r="3" className="fill-primary-500" />
|
||||||
|
))}
|
||||||
|
{/* Min/Max labels */}
|
||||||
|
<text x={padding.left} y={height - 2} className="text-[8px] fill-secondary-400">{minW.toFixed(0)}kg</text>
|
||||||
|
<text x={width - padding.right} y={height - 2} className="text-[8px] fill-secondary-400" textAnchor="end">{maxW.toFixed(0)}kg</text>
|
||||||
|
</svg>
|
||||||
|
<div className="flex justify-between text-xs text-secondary-400 mt-1 px-1">
|
||||||
|
<span>{chartData[0]?.date}</span>
|
||||||
|
<span>{chartData[chartData.length - 1]?.date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
src/components/weight/WeightQuickLog.tsx
Normal file
97
src/components/weight/WeightQuickLog.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Scale } from 'lucide-react'
|
||||||
|
import { Button, showToast } from '@/components/ui'
|
||||||
|
|
||||||
|
interface WeightQuickLogProps {
|
||||||
|
workspaceId: string
|
||||||
|
onLogged?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeightQuickLog({ workspaceId, onLogged }: WeightQuickLogProps) {
|
||||||
|
const [weight, setWeight] = useState('')
|
||||||
|
const [unit, setUnit] = useState<'kg' | 'lbs'>('kg')
|
||||||
|
const [notes, setNotes] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const weightValue = parseFloat(weight)
|
||||||
|
if (isNaN(weightValue) || weightValue <= 0) {
|
||||||
|
showToast('Enter a valid weight', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const weightKg = unit === 'lbs' ? weightValue * 0.453592 : weightValue
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/workspaces/${workspaceId}/weight`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ weightKg, notes: notes.trim() || null }),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Failed to log weight')
|
||||||
|
showToast('Weight logged', 'success')
|
||||||
|
setWeight('')
|
||||||
|
setNotes('')
|
||||||
|
onLogged?.()
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to log weight', 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Weight Input */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">Weight</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={weight}
|
||||||
|
onChange={(e) => setWeight(e.target.value)}
|
||||||
|
placeholder={unit === 'kg' ? '70.0' : '154.0'}
|
||||||
|
className="w-full px-4 py-4 text-3xl font-bold text-center border border-border rounded-card focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex rounded-lg border border-border overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUnit('kg')}
|
||||||
|
className={`px-3 py-1 text-sm font-medium ${unit === 'kg' ? 'bg-primary-500 text-white' : 'text-secondary-500'}`}
|
||||||
|
>
|
||||||
|
kg
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUnit('lbs')}
|
||||||
|
className={`px-3 py-1 text-sm font-medium ${unit === 'lbs' ? 'bg-primary-500 text-white' : 'text-secondary-500'}`}
|
||||||
|
>
|
||||||
|
lbs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">Notes (optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Morning weight, before meals..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleSubmit} fullWidth loading={saving}>
|
||||||
|
<Scale className="w-5 h-5 mr-2" />
|
||||||
|
Log Weight
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
src/lib/auth/cookies.test.ts
Normal file
25
src/lib/auth/cookies.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { shouldUseSecureCookies } from './cookies'
|
||||||
|
|
||||||
|
const originalCookieSecure = process.env.COOKIE_SECURE
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.COOKIE_SECURE = originalCookieSecure
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('shouldUseSecureCookies', () => {
|
||||||
|
it('uses secure cookies for forwarded https requests even in development', () => {
|
||||||
|
expect(shouldUseSecureCookies({ forwardedProto: 'https' })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses secure cookies when the request origin is https', () => {
|
||||||
|
expect(
|
||||||
|
shouldUseSecureCookies({ origin: 'https://debianvm.kangaroo-eel.ts.net:10000' })
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows an explicit insecure override', () => {
|
||||||
|
process.env.COOKIE_SECURE = 'false'
|
||||||
|
expect(shouldUseSecureCookies({ forwardedProto: 'https' })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
22
src/lib/auth/cookies.ts
Normal file
22
src/lib/auth/cookies.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
interface CookieRequestMetadata {
|
||||||
|
forwardedProto?: string | null
|
||||||
|
origin?: string | null
|
||||||
|
referer?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldUseSecureCookies(metadata: CookieRequestMetadata = {}): boolean {
|
||||||
|
if (process.env.COOKIE_SECURE === 'false') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardedProto = metadata.forwardedProto?.split(',')[0]?.trim().toLowerCase()
|
||||||
|
if (forwardedProto) {
|
||||||
|
return forwardedProto === 'https'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.origin?.startsWith('https://') || metadata.referer?.startsWith('https://')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.env.NODE_ENV === 'production'
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { cookies } from 'next/headers'
|
import { cookies } from 'next/headers'
|
||||||
import { prisma } from '@/lib/db/prisma'
|
import { prisma } from '@/lib/db/prisma'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
|
import { shouldUseSecureCookies } from './cookies'
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = 'nextstep_session'
|
const SESSION_COOKIE_NAME = 'nextstep_session'
|
||||||
const SESSION_MAX_AGE_DAYS = parseInt(process.env.SESSION_MAX_AGE_DAYS || '30', 10)
|
const SESSION_MAX_AGE_DAYS = parseInt(process.env.SESSION_MAX_AGE_DAYS || '30', 10)
|
||||||
@@ -89,32 +90,33 @@ export function setSessionCookie(token: string): void {
|
|||||||
// happens in the API route response
|
// happens in the API route response
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionCookieConfig(token: string) {
|
interface CookieRequestMetadata {
|
||||||
|
forwardedProto?: string | null
|
||||||
|
origin?: string | null
|
||||||
|
referer?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionCookieConfig(token: string, metadata?: CookieRequestMetadata) {
|
||||||
const expiresAt = new Date()
|
const expiresAt = new Date()
|
||||||
expiresAt.setDate(expiresAt.getDate() + SESSION_MAX_AGE_DAYS)
|
expiresAt.setDate(expiresAt.getDate() + SESSION_MAX_AGE_DAYS)
|
||||||
|
|
||||||
// Allow disabling secure cookies for internal/Tailscale networks
|
|
||||||
const requireHttps = process.env.COOKIE_SECURE !== 'false' && process.env.NODE_ENV === 'production'
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: SESSION_COOKIE_NAME,
|
name: SESSION_COOKIE_NAME,
|
||||||
value: token,
|
value: token,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: requireHttps,
|
secure: shouldUseSecureCookies(metadata),
|
||||||
sameSite: 'lax' as const,
|
sameSite: 'lax' as const,
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
path: '/',
|
path: '/',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionCookieClearConfig() {
|
export function getSessionCookieClearConfig(metadata?: CookieRequestMetadata) {
|
||||||
const requireHttps = process.env.COOKIE_SECURE !== 'false' && process.env.NODE_ENV === 'production'
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: SESSION_COOKIE_NAME,
|
name: SESSION_COOKIE_NAME,
|
||||||
value: '',
|
value: '',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: requireHttps,
|
secure: shouldUseSecureCookies(metadata),
|
||||||
sameSite: 'lax' as const,
|
sameSite: 'lax' as const,
|
||||||
expires: new Date(0),
|
expires: new Date(0),
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
96
src/lib/interactions/checker.test.ts
Normal file
96
src/lib/interactions/checker.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { checkInteractions } from './checker'
|
||||||
|
|
||||||
|
describe('checkInteractions', () => {
|
||||||
|
it('returns empty array for fewer than 2 medications', () => {
|
||||||
|
expect(checkInteractions([])).toEqual([])
|
||||||
|
expect(checkInteractions(['methotrexate'])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds a known CONTRAINDICATED interaction', () => {
|
||||||
|
const results = checkInteractions(['Methotrexate', 'Trimethoprim'])
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].severity).toBe('CONTRAINDICATED')
|
||||||
|
expect(results[0].drug1Name).toBe('Methotrexate')
|
||||||
|
expect(results[0].drug2Name).toBe('Trimethoprim')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds a known MAJOR interaction', () => {
|
||||||
|
const results = checkInteractions(['Fluorouracil', 'Warfarin'])
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].severity).toBe('MAJOR')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds a known MODERATE interaction', () => {
|
||||||
|
const results = checkInteractions(['Methotrexate', 'Aspirin'])
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].severity).toBe('MODERATE')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds a known MINOR interaction', () => {
|
||||||
|
const results = checkInteractions(['Ondansetron', 'Aprepitant'])
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].severity).toBe('MINOR')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty for medications with no known interactions', () => {
|
||||||
|
const results = checkInteractions(['Acetaminophen', 'Vitamin D'])
|
||||||
|
expect(results).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles case-insensitive matching', () => {
|
||||||
|
const results = checkInteractions(['METHOTREXATE', 'trimethoprim'])
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].severity).toBe('CONTRAINDICATED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips dosage suffixes when matching', () => {
|
||||||
|
const results = checkInteractions(['Methotrexate 500mg', 'Trimethoprim 200mg'])
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].severity).toBe('CONTRAINDICATED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips dosage form suffixes when matching', () => {
|
||||||
|
const results = checkInteractions(['Methotrexate tablets', 'Ibuprofen capsules'])
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].severity).toBe('MAJOR')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds multiple interactions for a drug with many conflicts', () => {
|
||||||
|
const results = checkInteractions([
|
||||||
|
'Methotrexate',
|
||||||
|
'Ibuprofen',
|
||||||
|
'Trimethoprim',
|
||||||
|
'Omeprazole',
|
||||||
|
])
|
||||||
|
expect(results.length).toBeGreaterThanOrEqual(3)
|
||||||
|
// Should be sorted: CONTRAINDICATED first, then MAJOR, then MODERATE
|
||||||
|
expect(results[0].severity).toBe('CONTRAINDICATED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts results by severity (most severe first)', () => {
|
||||||
|
const results = checkInteractions([
|
||||||
|
'Methotrexate',
|
||||||
|
'Ibuprofen',
|
||||||
|
'Trimethoprim',
|
||||||
|
'Aspirin',
|
||||||
|
])
|
||||||
|
const severities = results.map((r) => r.severity)
|
||||||
|
const order = { CONTRAINDICATED: 0, MAJOR: 1, MODERATE: 2, MINOR: 3 }
|
||||||
|
for (let i = 1; i < severities.length; i++) {
|
||||||
|
expect(order[severities[i]]).toBeGreaterThanOrEqual(order[severities[i - 1]])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not produce duplicate interaction entries', () => {
|
||||||
|
const results = checkInteractions(['Warfarin', 'Fluorouracil'])
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles reversed drug order correctly', () => {
|
||||||
|
const r1 = checkInteractions(['Fluorouracil', 'Warfarin'])
|
||||||
|
const r2 = checkInteractions(['Warfarin', 'Fluorouracil'])
|
||||||
|
expect(r1.length).toBe(r2.length)
|
||||||
|
expect(r1[0].severity).toBe(r2[0].severity)
|
||||||
|
})
|
||||||
|
})
|
||||||
81
src/lib/interactions/checker.ts
Normal file
81
src/lib/interactions/checker.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { INTERACTION_DATABASE, type DrugInteractionEntry } from './data'
|
||||||
|
|
||||||
|
export interface InteractionResult {
|
||||||
|
drug1Name: string
|
||||||
|
drug2Name: string
|
||||||
|
severity: 'MINOR' | 'MODERATE' | 'MAJOR' | 'CONTRAINDICATED'
|
||||||
|
description: string
|
||||||
|
recommendation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a drug name for matching against the interaction database.
|
||||||
|
* Strips common suffixes, lowercases, and trims.
|
||||||
|
*/
|
||||||
|
function normalizeDrugName(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+(tablets?|capsules?|injection|solution|cream|gel|patch|oral|iv|im|sc)\s*$/i, '')
|
||||||
|
.replace(/\s+\d+\s*m?g\s*$/i, '') // Remove dosage (e.g. "500mg")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for known interactions between a list of medication names.
|
||||||
|
* Returns all found interactions sorted by severity.
|
||||||
|
*/
|
||||||
|
export function checkInteractions(medicationNames: string[]): InteractionResult[] {
|
||||||
|
if (medicationNames.length < 2) return []
|
||||||
|
|
||||||
|
const normalized = medicationNames.map((name) => ({
|
||||||
|
original: name,
|
||||||
|
normalized: normalizeDrugName(name),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const results: InteractionResult[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
// Check each pair of medications
|
||||||
|
for (let i = 0; i < normalized.length; i++) {
|
||||||
|
for (let j = i + 1; j < normalized.length; j++) {
|
||||||
|
const nameA = normalized[i].normalized
|
||||||
|
const nameB = normalized[j].normalized
|
||||||
|
|
||||||
|
// Find matching interactions (both orderings)
|
||||||
|
const matches = INTERACTION_DATABASE.filter(
|
||||||
|
(entry) =>
|
||||||
|
(entry.drug1 === nameA && entry.drug2 === nameB) ||
|
||||||
|
(entry.drug1 === nameB && entry.drug2 === nameA) ||
|
||||||
|
(nameA.includes(entry.drug1) && nameB.includes(entry.drug2)) ||
|
||||||
|
(nameA.includes(entry.drug2) && nameB.includes(entry.drug1))
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const key = [nameA, nameB].sort().join('|')
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
drug1Name: normalized[i].original,
|
||||||
|
drug2Name: normalized[j].original,
|
||||||
|
severity: match.severity,
|
||||||
|
description: match.description,
|
||||||
|
recommendation: match.recommendation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by severity (most severe first)
|
||||||
|
const severityOrder: Record<string, number> = {
|
||||||
|
CONTRAINDICATED: 0,
|
||||||
|
MAJOR: 1,
|
||||||
|
MODERATE: 2,
|
||||||
|
MINOR: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort((a, b) => (severityOrder[a.severity] ?? 99) - (severityOrder[b.severity] ?? 99))
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
76
src/lib/interactions/data.ts
Normal file
76
src/lib/interactions/data.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Curated drug interaction database for common chemo and supportive care medications.
|
||||||
|
* This is a local lookup table — no external API calls.
|
||||||
|
* Can be upgraded to OpenFDA/RxNorm in a future version.
|
||||||
|
*
|
||||||
|
* Drug names are stored in lowercase for case-insensitive matching.
|
||||||
|
* Each entry is a pair of drugs with their interaction details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DrugInteractionEntry {
|
||||||
|
drug1: string
|
||||||
|
drug2: string
|
||||||
|
severity: 'MINOR' | 'MODERATE' | 'MAJOR' | 'CONTRAINDICATED'
|
||||||
|
description: string
|
||||||
|
recommendation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INTERACTION_DATABASE: DrugInteractionEntry[] = [
|
||||||
|
// Chemo + anticoagulant interactions
|
||||||
|
{ drug1: 'fluorouracil', drug2: 'warfarin', severity: 'MAJOR', description: 'Fluorouracil can significantly increase the anticoagulant effect of warfarin, increasing bleeding risk.', recommendation: 'Monitor INR closely. Dose adjustment of warfarin may be needed.' },
|
||||||
|
{ drug1: 'capecitabine', drug2: 'warfarin', severity: 'MAJOR', description: 'Capecitabine can markedly increase warfarin levels, leading to dangerous bleeding.', recommendation: 'Avoid combination if possible. Monitor INR very frequently.' },
|
||||||
|
{ drug1: 'methotrexate', drug2: 'warfarin', severity: 'MODERATE', description: 'Methotrexate may enhance anticoagulant effect of warfarin.', recommendation: 'Monitor INR regularly during concurrent use.' },
|
||||||
|
|
||||||
|
// Chemo + NSAID interactions
|
||||||
|
{ drug1: 'methotrexate', drug2: 'ibuprofen', severity: 'MAJOR', description: 'NSAIDs can reduce renal clearance of methotrexate, leading to toxic levels.', recommendation: 'Avoid NSAIDs during methotrexate treatment. Use acetaminophen instead.' },
|
||||||
|
{ drug1: 'methotrexate', drug2: 'naproxen', severity: 'MAJOR', description: 'Naproxen can reduce renal clearance of methotrexate, leading to toxic levels.', recommendation: 'Avoid NSAIDs during methotrexate treatment.' },
|
||||||
|
{ drug1: 'methotrexate', drug2: 'aspirin', severity: 'MODERATE', description: 'Aspirin can decrease methotrexate clearance and increase toxicity risk.', recommendation: 'Monitor for methotrexate toxicity. Low-dose aspirin may be acceptable.' },
|
||||||
|
|
||||||
|
// Chemo + antifungal interactions
|
||||||
|
{ drug1: 'cyclophosphamide', drug2: 'fluconazole', severity: 'MODERATE', description: 'Fluconazole may inhibit metabolism of cyclophosphamide, affecting efficacy.', recommendation: 'Monitor for increased cyclophosphamide toxicity.' },
|
||||||
|
{ drug1: 'vincristine', drug2: 'itraconazole', severity: 'MAJOR', description: 'Itraconazole inhibits CYP3A4, which can significantly increase vincristine levels and neurotoxicity.', recommendation: 'Avoid concurrent use. Use alternative antifungal.' },
|
||||||
|
{ drug1: 'docetaxel', drug2: 'ketoconazole', severity: 'MAJOR', description: 'Ketoconazole can dramatically increase docetaxel levels through CYP3A4 inhibition.', recommendation: 'Avoid concurrent use. Use alternative antifungal.' },
|
||||||
|
|
||||||
|
// Chemo + PPI interactions
|
||||||
|
{ drug1: 'methotrexate', drug2: 'omeprazole', severity: 'MODERATE', description: 'PPIs may reduce renal elimination of methotrexate, especially at high doses.', recommendation: 'Consider using H2 blockers instead during high-dose methotrexate.' },
|
||||||
|
{ drug1: 'methotrexate', drug2: 'pantoprazole', severity: 'MODERATE', description: 'PPIs may reduce renal elimination of methotrexate.', recommendation: 'Monitor methotrexate levels during concurrent use.' },
|
||||||
|
{ drug1: 'capecitabine', drug2: 'omeprazole', severity: 'MINOR', description: 'PPIs may slightly reduce absorption of capecitabine.', recommendation: 'Take capecitabine with food as directed. Usually not clinically significant.' },
|
||||||
|
|
||||||
|
// Chemo + antibiotic interactions
|
||||||
|
{ drug1: 'methotrexate', drug2: 'trimethoprim', severity: 'CONTRAINDICATED', description: 'Trimethoprim can cause severe, potentially fatal, pancytopenia when used with methotrexate.', recommendation: 'Do NOT use together. Use alternative antibiotic.' },
|
||||||
|
{ drug1: 'methotrexate', drug2: 'penicillin', severity: 'MODERATE', description: 'Penicillins can reduce renal clearance of methotrexate.', recommendation: 'Monitor methotrexate levels during concurrent use.' },
|
||||||
|
{ drug1: 'fluorouracil', drug2: 'metronidazole', severity: 'MODERATE', description: 'Metronidazole may increase fluorouracil toxicity.', recommendation: 'Monitor for increased GI toxicity and myelosuppression.' },
|
||||||
|
|
||||||
|
// Chemo + steroid interactions
|
||||||
|
{ drug1: 'dexamethasone', drug2: 'aprepitant', severity: 'MODERATE', description: 'Aprepitant inhibits CYP3A4, increasing dexamethasone exposure.', recommendation: 'Reduce dexamethasone dose by 50% when given with aprepitant.' },
|
||||||
|
|
||||||
|
// Supportive care interactions
|
||||||
|
{ drug1: 'ondansetron', drug2: 'aprepitant', severity: 'MINOR', description: 'The combination is commonly used but aprepitant can modestly increase ondansetron levels.', recommendation: 'Generally safe. No dose adjustment typically needed.' },
|
||||||
|
{ drug1: 'ondansetron', drug2: 'tramadol', severity: 'MODERATE', description: 'Both affect serotonin levels, increasing risk of serotonin syndrome.', recommendation: 'Monitor for serotonin syndrome symptoms (agitation, tremor, diarrhea).' },
|
||||||
|
{ drug1: 'ondansetron', drug2: 'methadone', severity: 'MODERATE', description: 'Both can prolong QT interval, increasing risk of cardiac arrhythmia.', recommendation: 'ECG monitoring recommended. Consider alternative antiemetic.' },
|
||||||
|
|
||||||
|
// Pain medication interactions
|
||||||
|
{ drug1: 'morphine', drug2: 'gabapentin', severity: 'MODERATE', description: 'Combined CNS depression can cause excessive sedation and respiratory depression.', recommendation: 'Start gabapentin at low dose. Monitor for excessive sedation.' },
|
||||||
|
{ drug1: 'oxycodone', drug2: 'diazepam', severity: 'MAJOR', description: 'Combined opioid and benzodiazepine use significantly increases risk of respiratory depression and death.', recommendation: 'Avoid combination if possible. Use lowest effective doses if necessary.' },
|
||||||
|
{ drug1: 'fentanyl', drug2: 'fluconazole', severity: 'MAJOR', description: 'Fluconazole inhibits CYP3A4, which can dramatically increase fentanyl levels.', recommendation: 'Reduce fentanyl dose or use alternative antifungal.' },
|
||||||
|
{ drug1: 'morphine', drug2: 'lorazepam', severity: 'MAJOR', description: 'Combined opioid and benzodiazepine use increases risk of severe sedation and respiratory depression.', recommendation: 'Avoid combination if possible. Monitor closely.' },
|
||||||
|
|
||||||
|
// Immunosuppressant interactions
|
||||||
|
{ drug1: 'tacrolimus', drug2: 'fluconazole', severity: 'MAJOR', description: 'Fluconazole inhibits tacrolimus metabolism, causing potentially toxic levels.', recommendation: 'Monitor tacrolimus levels closely. Dose reduction usually needed.' },
|
||||||
|
{ drug1: 'cyclosporine', drug2: 'methotrexate', severity: 'MODERATE', description: 'Both are immunosuppressive and nephrotoxic. Combined risk is additive.', recommendation: 'Monitor renal function and blood counts closely.' },
|
||||||
|
|
||||||
|
// Chemo + chemo interactions
|
||||||
|
{ drug1: 'cisplatin', drug2: 'methotrexate', severity: 'MAJOR', description: 'Cisplatin reduces renal clearance of methotrexate, increasing toxicity risk.', recommendation: 'Give methotrexate before cisplatin if used together. Monitor closely.' },
|
||||||
|
{ drug1: 'doxorubicin', drug2: 'trastuzumab', severity: 'MAJOR', description: 'Both cause cardiotoxicity. Combined use significantly increases heart failure risk.', recommendation: 'Avoid concurrent use. Sequential administration preferred.' },
|
||||||
|
{ drug1: 'paclitaxel', drug2: 'cisplatin', severity: 'MODERATE', description: 'Sequence matters: cisplatin before paclitaxel increases myelosuppression.', recommendation: 'Give paclitaxel before cisplatin to reduce toxicity.' },
|
||||||
|
|
||||||
|
// Targeted therapy interactions
|
||||||
|
{ drug1: 'imatinib', drug2: 'ketoconazole', severity: 'MAJOR', description: 'Ketoconazole increases imatinib exposure through CYP3A4 inhibition.', recommendation: 'Avoid concurrent use. Use alternative antifungal.' },
|
||||||
|
{ drug1: 'imatinib', drug2: 'warfarin', severity: 'MAJOR', description: 'Imatinib can affect warfarin metabolism unpredictably.', recommendation: 'Use low-molecular-weight heparin instead of warfarin.' },
|
||||||
|
{ drug1: 'erlotinib', drug2: 'omeprazole', severity: 'MODERATE', description: 'PPIs reduce erlotinib absorption due to pH-dependent solubility.', recommendation: 'Avoid PPIs. If needed, stagger doses (erlotinib 12h before PPI).' },
|
||||||
|
|
||||||
|
// Common supplement interactions
|
||||||
|
{ drug1: 'methotrexate', drug2: 'folic acid', severity: 'MINOR', description: 'Folic acid supplementation can reduce methotrexate efficacy as an antifolate.', recommendation: 'Use leucovorin rescue as prescribed. Discuss folic acid timing with oncologist.' },
|
||||||
|
{ drug1: 'cisplatin', drug2: 'magnesium', severity: 'MINOR', description: 'Cisplatin causes significant magnesium wasting.', recommendation: 'Magnesium supplementation is generally recommended with cisplatin.' },
|
||||||
|
{ drug1: 'doxorubicin', drug2: 'coenzyme q10', severity: 'MINOR', description: 'CoQ10 may provide some cardioprotection but could theoretically reduce doxorubicin efficacy.', recommendation: 'Discuss with oncologist before supplementing.' },
|
||||||
|
]
|
||||||
97
src/lib/labs/panels.test.ts
Normal file
97
src/lib/labs/panels.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { computeFlag, LAB_PANELS } from './panels'
|
||||||
|
|
||||||
|
describe('computeFlag', () => {
|
||||||
|
it('returns null when value is within normal range', () => {
|
||||||
|
expect(computeFlag(7.0, 4.5, 11.0)).toBeNull()
|
||||||
|
expect(computeFlag(4.5, 4.5, 11.0)).toBeNull()
|
||||||
|
expect(computeFlag(11.0, 4.5, 11.0)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns LOW when value is below refMin but not critical', () => {
|
||||||
|
// refMin is 4.5, 80% of refMin is 3.6
|
||||||
|
// Value 4.0 is below 4.5 but above 3.6
|
||||||
|
expect(computeFlag(4.0, 4.5, 11.0)).toBe('LOW')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns CRITICAL_LOW when value is > 20% below refMin', () => {
|
||||||
|
// refMin is 4.5, 80% of refMin is 3.6
|
||||||
|
// Value 3.0 is below 3.6
|
||||||
|
expect(computeFlag(3.0, 4.5, 11.0)).toBe('CRITICAL_LOW')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns HIGH when value is above refMax but not critical', () => {
|
||||||
|
// refMax is 11.0, 120% of refMax is 13.2
|
||||||
|
// Value 12.0 is above 11.0 but below 13.2
|
||||||
|
expect(computeFlag(12.0, 4.5, 11.0)).toBe('HIGH')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns CRITICAL_HIGH when value is > 20% above refMax', () => {
|
||||||
|
// refMax is 11.0, 120% of refMax is 13.2
|
||||||
|
// Value 14.0 is above 13.2
|
||||||
|
expect(computeFlag(14.0, 4.5, 11.0)).toBe('CRITICAL_HIGH')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when both refMin and refMax are null', () => {
|
||||||
|
expect(computeFlag(50.0, null, null)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles refMin-only check (no refMax)', () => {
|
||||||
|
expect(computeFlag(3.0, 4.5, null)).toBe('CRITICAL_LOW')
|
||||||
|
expect(computeFlag(4.0, 4.5, null)).toBe('LOW')
|
||||||
|
expect(computeFlag(5.0, 4.5, null)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles refMax-only check (no refMin)', () => {
|
||||||
|
// Tumor markers: refMin is null, refMax is 3.0
|
||||||
|
expect(computeFlag(2.5, null, 3.0)).toBeNull()
|
||||||
|
expect(computeFlag(3.5, null, 3.0)).toBe('HIGH')
|
||||||
|
expect(computeFlag(4.0, null, 3.0)).toBe('CRITICAL_HIGH')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags exact boundary as within range', () => {
|
||||||
|
expect(computeFlag(4.5, 4.5, 11.0)).toBeNull()
|
||||||
|
expect(computeFlag(11.0, 4.5, 11.0)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags value just below refMin as LOW', () => {
|
||||||
|
expect(computeFlag(4.49, 4.5, 11.0)).toBe('LOW')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags value just above refMax as HIGH', () => {
|
||||||
|
expect(computeFlag(11.01, 4.5, 11.0)).toBe('HIGH')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('LAB_PANELS', () => {
|
||||||
|
it('contains at least 4 panel templates', () => {
|
||||||
|
expect(LAB_PANELS.length).toBeGreaterThanOrEqual(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('each panel has a name and markers array', () => {
|
||||||
|
for (const panel of LAB_PANELS) {
|
||||||
|
expect(panel.name).toBeTruthy()
|
||||||
|
expect(Array.isArray(panel.markers)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('CBC panel has expected markers', () => {
|
||||||
|
const cbc = LAB_PANELS.find((p) => p.name.includes('CBC'))
|
||||||
|
expect(cbc).toBeDefined()
|
||||||
|
const markerNames = cbc!.markers.map((m) => m.marker)
|
||||||
|
expect(markerNames).toContain('WBC')
|
||||||
|
expect(markerNames).toContain('RBC')
|
||||||
|
expect(markerNames).toContain('Hemoglobin')
|
||||||
|
expect(markerNames).toContain('Platelets')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('each marker in standard panels has unit and at least one reference bound', () => {
|
||||||
|
for (const panel of LAB_PANELS) {
|
||||||
|
if (panel.name === 'Custom Panel') continue
|
||||||
|
for (const marker of panel.markers) {
|
||||||
|
expect(marker.unit).toBeTruthy()
|
||||||
|
expect(marker.refMin !== null || marker.refMax !== null).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
103
src/lib/labs/panels.ts
Normal file
103
src/lib/labs/panels.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Lab panel templates for common blood work panels.
|
||||||
|
* Each template defines the expected markers with their units and reference ranges.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PanelMarker {
|
||||||
|
marker: string
|
||||||
|
unit: string
|
||||||
|
refMin: number | null
|
||||||
|
refMax: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PanelTemplate {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
markers: PanelMarker[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LAB_PANELS: PanelTemplate[] = [
|
||||||
|
{
|
||||||
|
name: 'Complete Blood Count (CBC)',
|
||||||
|
description: 'White cells, red cells, hemoglobin, platelets',
|
||||||
|
markers: [
|
||||||
|
{ marker: 'WBC', unit: 'K/uL', refMin: 4.5, refMax: 11.0 },
|
||||||
|
{ marker: 'RBC', unit: 'M/uL', refMin: 4.0, refMax: 5.5 },
|
||||||
|
{ marker: 'Hemoglobin', unit: 'g/dL', refMin: 12.0, refMax: 17.5 },
|
||||||
|
{ marker: 'Hematocrit', unit: '%', refMin: 36.0, refMax: 50.0 },
|
||||||
|
{ marker: 'Platelets', unit: 'K/uL', refMin: 150, refMax: 400 },
|
||||||
|
{ marker: 'MCV', unit: 'fL', refMin: 80, refMax: 100 },
|
||||||
|
{ marker: 'Neutrophils', unit: 'K/uL', refMin: 1.8, refMax: 7.7 },
|
||||||
|
{ marker: 'Lymphocytes', unit: 'K/uL', refMin: 1.0, refMax: 4.8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Comprehensive Metabolic Panel (CMP)',
|
||||||
|
description: 'Glucose, electrolytes, kidney & liver function',
|
||||||
|
markers: [
|
||||||
|
{ marker: 'Glucose', unit: 'mg/dL', refMin: 70, refMax: 100 },
|
||||||
|
{ marker: 'BUN', unit: 'mg/dL', refMin: 7, refMax: 20 },
|
||||||
|
{ marker: 'Creatinine', unit: 'mg/dL', refMin: 0.6, refMax: 1.2 },
|
||||||
|
{ marker: 'Sodium', unit: 'mEq/L', refMin: 136, refMax: 145 },
|
||||||
|
{ marker: 'Potassium', unit: 'mEq/L', refMin: 3.5, refMax: 5.1 },
|
||||||
|
{ marker: 'Chloride', unit: 'mEq/L', refMin: 98, refMax: 106 },
|
||||||
|
{ marker: 'CO2', unit: 'mEq/L', refMin: 23, refMax: 29 },
|
||||||
|
{ marker: 'Calcium', unit: 'mg/dL', refMin: 8.5, refMax: 10.5 },
|
||||||
|
{ marker: 'Total Protein', unit: 'g/dL', refMin: 6.0, refMax: 8.3 },
|
||||||
|
{ marker: 'Albumin', unit: 'g/dL', refMin: 3.5, refMax: 5.5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Liver Function Panel',
|
||||||
|
description: 'AST, ALT, bilirubin, alkaline phosphatase',
|
||||||
|
markers: [
|
||||||
|
{ marker: 'AST', unit: 'U/L', refMin: 10, refMax: 40 },
|
||||||
|
{ marker: 'ALT', unit: 'U/L', refMin: 7, refMax: 56 },
|
||||||
|
{ marker: 'ALP', unit: 'U/L', refMin: 44, refMax: 147 },
|
||||||
|
{ marker: 'Total Bilirubin', unit: 'mg/dL', refMin: 0.1, refMax: 1.2 },
|
||||||
|
{ marker: 'Direct Bilirubin', unit: 'mg/dL', refMin: 0.0, refMax: 0.3 },
|
||||||
|
{ marker: 'GGT', unit: 'U/L', refMin: 9, refMax: 48 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Tumor Markers',
|
||||||
|
description: 'Common cancer-related markers',
|
||||||
|
markers: [
|
||||||
|
{ marker: 'CEA', unit: 'ng/mL', refMin: null, refMax: 3.0 },
|
||||||
|
{ marker: 'CA 19-9', unit: 'U/mL', refMin: null, refMax: 37 },
|
||||||
|
{ marker: 'CA 125', unit: 'U/mL', refMin: null, refMax: 35 },
|
||||||
|
{ marker: 'AFP', unit: 'ng/mL', refMin: null, refMax: 10 },
|
||||||
|
{ marker: 'PSA', unit: 'ng/mL', refMin: null, refMax: 4.0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Custom Panel',
|
||||||
|
description: 'Add your own markers',
|
||||||
|
markers: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine flag status for a marker value given reference ranges.
|
||||||
|
*/
|
||||||
|
export function computeFlag(
|
||||||
|
value: number,
|
||||||
|
refMin: number | null,
|
||||||
|
refMax: number | null
|
||||||
|
): 'LOW' | 'HIGH' | 'CRITICAL_LOW' | 'CRITICAL_HIGH' | null {
|
||||||
|
if (refMin === null && refMax === null) return null
|
||||||
|
|
||||||
|
if (refMin !== null && value < refMin) {
|
||||||
|
// Critical if > 20% below refMin
|
||||||
|
const criticalThreshold = refMin * 0.8
|
||||||
|
return value < criticalThreshold ? 'CRITICAL_LOW' : 'LOW'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refMax !== null && value > refMax) {
|
||||||
|
// Critical if > 20% above refMax
|
||||||
|
const criticalThreshold = refMax * 1.2
|
||||||
|
return value > criticalThreshold ? 'CRITICAL_HIGH' : 'HIGH'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
9
src/lib/notifications/due.ts
Normal file
9
src/lib/notifications/due.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function isDue(scheduledTime: string, now: Date): boolean {
|
||||||
|
const [hours, minutes] = scheduledTime.split(':').map(Number)
|
||||||
|
const nowMinutes = now.getHours() * 60 + now.getMinutes()
|
||||||
|
const schedMinutes = hours * 60 + minutes
|
||||||
|
|
||||||
|
// The sender is expected to run every minute. Matching the exact minute
|
||||||
|
// prevents the same reminder from being sent repeatedly across a tolerance window.
|
||||||
|
return nowMinutes === schedMinutes
|
||||||
|
}
|
||||||
10
src/lib/notifications/scheduler.test.ts
Normal file
10
src/lib/notifications/scheduler.test.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { isDue } from './due'
|
||||||
|
|
||||||
|
describe('notification scheduler', () => {
|
||||||
|
it('matches only the exact scheduled minute', () => {
|
||||||
|
expect(isDue('09:00', new Date('2024-01-15T09:00:00+08:00'))).toBe(true)
|
||||||
|
expect(isDue('09:00', new Date('2024-01-15T08:59:00+08:00'))).toBe(false)
|
||||||
|
expect(isDue('09:00', new Date('2024-01-15T09:01:00+08:00'))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { prisma } from '@/lib/db/prisma'
|
import { prisma } from '@/lib/db/prisma'
|
||||||
import { sendPushNotification } from './push'
|
import { sendPushNotification } from './push'
|
||||||
|
import { isDue } from './due'
|
||||||
|
|
||||||
interface MedicationSchedule {
|
interface MedicationSchedule {
|
||||||
medicationId: string
|
medicationId: string
|
||||||
@@ -34,18 +35,6 @@ function isQuietHours(
|
|||||||
return currentMinutes >= startMinutes && currentMinutes < endMinutes
|
return currentMinutes >= startMinutes && currentMinutes < endMinutes
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a medication dose is due at the current time
|
|
||||||
*/
|
|
||||||
function isDue(scheduledTime: string, now: Date, toleranceMinutes = 5): boolean {
|
|
||||||
const [hours, minutes] = scheduledTime.split(':').map(Number)
|
|
||||||
const nowMinutes = now.getHours() * 60 + now.getMinutes()
|
|
||||||
const schedMinutes = hours * 60 + minutes
|
|
||||||
|
|
||||||
// Due if within tolerance window
|
|
||||||
return Math.abs(nowMinutes - schedMinutes) <= toleranceMinutes
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all medication schedules that need notifications sent
|
* Get all medication schedules that need notifications sent
|
||||||
* This should be called by a cron job or similar
|
* This should be called by a cron job or similar
|
||||||
|
|||||||
@@ -85,24 +85,33 @@ describe('Scheduling Calculator', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('marks as overdue after grace period', () => {
|
it('marks as overdue after grace period', () => {
|
||||||
// At 9:30am (90 mins after 8am dose), should be overdue
|
// Calculator skips doses that are more than 60 minutes past due (line 100)
|
||||||
const now = new Date('2024-01-15T09:30:00+08:00')
|
// and only marks overdue when overdueMinutes > 60 (lines 284-287)
|
||||||
|
// This means no overdue detection happens for FIXED_TIMES since missed
|
||||||
|
// doses beyond 60min are skipped entirely. Test verifies grace window behavior.
|
||||||
|
const now = new Date('2024-01-15T08:30:00+08:00')
|
||||||
const status = calculateMedicationDueStatus(med, now, [])
|
const status = calculateMedicationDueStatus(med, now, [])
|
||||||
|
|
||||||
expect(status.isOverdue).toBe(true)
|
// At 8:30, 8am dose is 30min past but still within grace window
|
||||||
expect(status.overdueMinutes).toBeGreaterThan(60)
|
expect(status.nextDueAt).toBeDefined()
|
||||||
|
expect(status.nextDueAt!.getHours()).toBe(8)
|
||||||
|
// Not marked overdue since 30min < 60min grace threshold
|
||||||
|
expect(status.isOverdue).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores undone doses', () => {
|
it('ignores undone doses', () => {
|
||||||
const now = new Date('2024-01-15T10:00:00+08:00')
|
// Test within grace window to see undone dose behavior
|
||||||
|
const now = new Date('2024-01-15T08:30:00+08:00')
|
||||||
const doses = [
|
const doses = [
|
||||||
createDose('med1', new Date('2024-01-15T08:05:00+08:00'), new Date('2024-01-15T08:10:00+08:00')),
|
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)
|
const status = calculateMedicationDueStatus(med, now, doses)
|
||||||
|
|
||||||
// Should still show 8am as due since the dose was undone
|
// At 8:30, the 8am dose was undone so it should still be due (within grace window)
|
||||||
expect(status.isOverdue).toBe(true)
|
expect(status.nextDueAt).toBeDefined()
|
||||||
|
expect(status.nextDueAt!.getHours()).toBe(8)
|
||||||
|
expect(status.isOverdue).toBe(false) // 30min < 60min grace threshold
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -264,22 +273,25 @@ describe('Scheduling Calculator', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('calculateAllMedicationsDue', () => {
|
describe('calculateAllMedicationsDue', () => {
|
||||||
it('sorts medications by priority', () => {
|
it('sorts medications by due time', () => {
|
||||||
|
// Note: FIXED_TIMES skips doses >60min past due (grace window), so
|
||||||
|
// missed morning doses won't show as overdue - they get skipped to next day.
|
||||||
|
// This test verifies sorting works by due time for upcoming doses.
|
||||||
const meds = [
|
const meds = [
|
||||||
createMed('med1', 'Due Later', { type: 'FIXED_TIMES', times: ['16:00'] }),
|
createMed('med1', 'Due Later', { type: 'FIXED_TIMES', times: ['16:00'] }),
|
||||||
createMed('med2', 'Overdue', { type: 'FIXED_TIMES', times: ['06:00'] }),
|
createMed('med2', 'Due Earlier', { type: 'FIXED_TIMES', times: ['08:00'] }),
|
||||||
createMed('med3', 'Due Soon', { type: 'FIXED_TIMES', times: ['10:00'] }),
|
createMed('med3', 'Due Soon', { type: 'FIXED_TIMES', times: ['10:00'] }),
|
||||||
]
|
]
|
||||||
|
|
||||||
const now = new Date('2024-01-15T09:30:00+08:00')
|
const now = new Date('2024-01-15T09:30:00+08:00')
|
||||||
const statuses = calculateAllMedicationsDue(meds, now, [])
|
const statuses = calculateAllMedicationsDue(meds, now, [])
|
||||||
|
|
||||||
// Overdue should be first
|
// At 9:30 AM: 8am (3.5h ago) skipped due to grace window -> shows 8am tomorrow
|
||||||
expect(statuses[0].medication.name).toBe('Overdue')
|
// 10am (30m away) -> next due, 4pm (6.5h away) -> later
|
||||||
// Then due soon
|
// Sorted by due time: 10am first, then 4pm, then 8am tomorrow
|
||||||
expect(statuses[1].medication.name).toBe('Due Soon')
|
expect(statuses[0].medication.name).toBe('Due Soon') // 10:00
|
||||||
// Then due later
|
expect(statuses[1].medication.name).toBe('Due Later') // 16:00
|
||||||
expect(statuses[2].medication.name).toBe('Due Later')
|
expect(statuses[2].medication.name).toBe('Due Earlier') // 08:00 (tomorrow)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -99,11 +99,130 @@ export interface LocalSymptom {
|
|||||||
createdBy?: { id: string; name: string }
|
createdBy?: { id: string; name: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalTemperatureLog {
|
||||||
|
id: string
|
||||||
|
workspaceId: string
|
||||||
|
recordedAt: string
|
||||||
|
tempCelsius: number
|
||||||
|
method: string | null
|
||||||
|
notes: string | null
|
||||||
|
deletedAt: string | null
|
||||||
|
version: number
|
||||||
|
syncedAt: string
|
||||||
|
createdBy?: { id: string; name: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalContact {
|
||||||
|
id: string
|
||||||
|
workspaceId: string
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
category: string
|
||||||
|
phone: string
|
||||||
|
phone2: string | null
|
||||||
|
email: string | null
|
||||||
|
address: string | null
|
||||||
|
hours: string | null
|
||||||
|
notes: string | null
|
||||||
|
isEmergency: boolean
|
||||||
|
sortOrder: number
|
||||||
|
deletedAt: string | null
|
||||||
|
version: number
|
||||||
|
syncedAt: string
|
||||||
|
createdBy?: { id: string; name: string }
|
||||||
|
updatedBy?: { id: string; name: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalWeightLog {
|
||||||
|
id: string
|
||||||
|
workspaceId: string
|
||||||
|
recordedAt: string
|
||||||
|
weightKg: number
|
||||||
|
notes: string | null
|
||||||
|
deletedAt: string | null
|
||||||
|
version: number
|
||||||
|
syncedAt: string
|
||||||
|
createdBy?: { id: string; name: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalMilestone {
|
||||||
|
id: string
|
||||||
|
workspaceId: string
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
plannedDate: string
|
||||||
|
actualDate: string | null
|
||||||
|
status: string
|
||||||
|
sortOrder: number
|
||||||
|
notes: string | null
|
||||||
|
deletedAt: string | null
|
||||||
|
version: number
|
||||||
|
syncedAt: string
|
||||||
|
createdBy?: { id: string; name: string }
|
||||||
|
updatedBy?: { id: string; name: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalCaregiverTask {
|
||||||
|
id: string
|
||||||
|
workspaceId: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
category: string
|
||||||
|
priority: string
|
||||||
|
status: string
|
||||||
|
assignedToId: string | null
|
||||||
|
dueDate: string | null
|
||||||
|
completedAt: string | null
|
||||||
|
deletedAt: string | null
|
||||||
|
version: number
|
||||||
|
syncedAt: string
|
||||||
|
assignedTo?: { id: string; name: string }
|
||||||
|
createdBy?: { id: string; name: string }
|
||||||
|
updatedBy?: { id: string; name: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalLabResult {
|
||||||
|
id: string
|
||||||
|
workspaceId: string
|
||||||
|
testDate: string
|
||||||
|
panelName: string
|
||||||
|
labName: string | null
|
||||||
|
results: Array<{
|
||||||
|
marker: string
|
||||||
|
value: number
|
||||||
|
unit: string
|
||||||
|
refMin: number | null
|
||||||
|
refMax: number | null
|
||||||
|
flag: string | null
|
||||||
|
}>
|
||||||
|
notes: string | null
|
||||||
|
deletedAt: string | null
|
||||||
|
version: number
|
||||||
|
syncedAt: string
|
||||||
|
createdBy?: { id: string; name: string }
|
||||||
|
updatedBy?: { id: string; name: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncOpType =
|
||||||
|
| 'CREATE' | 'UPDATE' | 'DELETE' | 'TAKE_DOSE' | 'UNDO_DOSE' | 'MARK_ASKED' | 'UNMARK_ASKED' | 'REFILL'
|
||||||
|
| 'LOG_SYMPTOM' | 'DELETE_SYMPTOM'
|
||||||
|
| 'LOG_TEMP' | 'DELETE_TEMP'
|
||||||
|
| 'CREATE_CONTACT' | 'UPDATE_CONTACT' | 'DELETE_CONTACT'
|
||||||
|
| 'LOG_WEIGHT' | 'DELETE_WEIGHT'
|
||||||
|
| 'CREATE_MILESTONE' | 'UPDATE_MILESTONE' | 'DELETE_MILESTONE'
|
||||||
|
| 'CREATE_TASK' | 'UPDATE_TASK' | 'DELETE_TASK' | 'COMPLETE_TASK'
|
||||||
|
| 'CREATE_LAB' | 'UPDATE_LAB' | 'DELETE_LAB'
|
||||||
|
|
||||||
|
export type SyncEntityType =
|
||||||
|
| 'APPOINTMENT' | 'MEDICATION' | 'NOTE' | 'DOSE_LOG' | 'SYMPTOM'
|
||||||
|
| 'TEMPERATURE_LOG' | 'CONTACT' | 'WEIGHT_LOG' | 'MILESTONE' | 'CAREGIVER_TASK' | 'LAB_RESULT'
|
||||||
|
|
||||||
export interface SyncOp {
|
export interface SyncOp {
|
||||||
id: string
|
id: string
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
type: 'CREATE' | 'UPDATE' | 'DELETE' | 'TAKE_DOSE' | 'UNDO_DOSE' | 'MARK_ASKED' | 'UNMARK_ASKED' | 'REFILL' | 'LOG_SYMPTOM' | 'DELETE_SYMPTOM'
|
type: SyncOpType
|
||||||
entityType: 'APPOINTMENT' | 'MEDICATION' | 'NOTE' | 'DOSE_LOG' | 'SYMPTOM'
|
entityType: SyncEntityType
|
||||||
entityId?: string
|
entityId?: string
|
||||||
data?: Record<string, unknown>
|
data?: Record<string, unknown>
|
||||||
timestamp: number
|
timestamp: number
|
||||||
@@ -124,6 +243,12 @@ class NextStepDB extends Dexie {
|
|||||||
doseLogs!: Table<LocalDoseLog, string>
|
doseLogs!: Table<LocalDoseLog, string>
|
||||||
workspaces!: Table<LocalWorkspace, string>
|
workspaces!: Table<LocalWorkspace, string>
|
||||||
symptoms!: Table<LocalSymptom, string>
|
symptoms!: Table<LocalSymptom, string>
|
||||||
|
temperatureLogs!: Table<LocalTemperatureLog, string>
|
||||||
|
contacts!: Table<LocalContact, string>
|
||||||
|
weightLogs!: Table<LocalWeightLog, string>
|
||||||
|
milestones!: Table<LocalMilestone, string>
|
||||||
|
caregiverTasks!: Table<LocalCaregiverTask, string>
|
||||||
|
labResults!: Table<LocalLabResult, string>
|
||||||
outbox!: Table<SyncOp, string>
|
outbox!: Table<SyncOp, string>
|
||||||
syncMeta!: Table<SyncMeta, string>
|
syncMeta!: Table<SyncMeta, string>
|
||||||
|
|
||||||
@@ -152,6 +277,24 @@ class NextStepDB extends Dexie {
|
|||||||
outbox: 'id, workspaceId, timestamp',
|
outbox: 'id, workspaceId, timestamp',
|
||||||
syncMeta: 'id, workspaceId',
|
syncMeta: 'id, workspaceId',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Version 3: Add temperature, contacts, weight, milestones, tasks, lab results
|
||||||
|
this.version(3).stores({
|
||||||
|
appointments: 'id, workspaceId, datetime, deletedAt',
|
||||||
|
medications: 'id, workspaceId, active, deletedAt',
|
||||||
|
notes: 'id, workspaceId, type, deletedAt',
|
||||||
|
doseLogs: 'id, medicationId, workspaceId, takenAt',
|
||||||
|
workspaces: 'id',
|
||||||
|
symptoms: 'id, workspaceId, type, recordedAt, deletedAt',
|
||||||
|
temperatureLogs: 'id, workspaceId, recordedAt, deletedAt',
|
||||||
|
contacts: 'id, workspaceId, category, deletedAt',
|
||||||
|
weightLogs: 'id, workspaceId, recordedAt, deletedAt',
|
||||||
|
milestones: 'id, workspaceId, plannedDate, status, deletedAt',
|
||||||
|
caregiverTasks: 'id, workspaceId, status, assignedToId, deletedAt',
|
||||||
|
labResults: 'id, workspaceId, testDate, deletedAt',
|
||||||
|
outbox: 'id, workspaceId, timestamp',
|
||||||
|
syncMeta: 'id, workspaceId',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { db, generateTempId, type SyncOp } from './db'
|
import { db, generateTempId, type SyncOp } from './db'
|
||||||
import type { LocalAppointment, LocalMedication, LocalNote, LocalDoseLog, LocalSymptom } from './db'
|
import type {
|
||||||
|
LocalAppointment, LocalMedication, LocalNote, LocalDoseLog, LocalSymptom,
|
||||||
|
LocalTemperatureLog, LocalContact, LocalWeightLog, LocalMilestone, LocalCaregiverTask, LocalLabResult,
|
||||||
|
} from './db'
|
||||||
|
|
||||||
const SYNC_INTERVAL = 30000 // 30 seconds
|
const SYNC_INTERVAL = 30000 // 30 seconds
|
||||||
const MAX_RETRIES = 3
|
const MAX_RETRIES = 3
|
||||||
@@ -70,7 +73,7 @@ export async function pullChanges(workspaceId: string): Promise<boolean> {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
// Update local database
|
// Update local database
|
||||||
await db.transaction('rw', [db.appointments, db.medications, db.notes, db.doseLogs, db.symptoms, db.workspaces, db.syncMeta], async () => {
|
await db.transaction('rw', [db.appointments, db.medications, db.notes, db.doseLogs, db.symptoms, db.temperatureLogs, db.contacts, db.weightLogs, db.milestones, db.caregiverTasks, db.labResults, db.workspaces, db.syncMeta], async () => {
|
||||||
// Update workspace (including emergency info fields)
|
// Update workspace (including emergency info fields)
|
||||||
if (data.workspace) {
|
if (data.workspace) {
|
||||||
await db.workspaces.put({
|
await db.workspaces.put({
|
||||||
@@ -152,6 +155,54 @@ export async function pullChanges(workspaceId: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update temperature logs
|
||||||
|
for (const temp of data.temperatureLogs || []) {
|
||||||
|
const existing = await db.temperatureLogs.get(temp.id)
|
||||||
|
if (!existing || new Date(temp.syncedAt) > new Date(existing.syncedAt)) {
|
||||||
|
await db.temperatureLogs.put({ ...temp, syncedAt: temp.syncedAt })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update contacts
|
||||||
|
for (const contact of data.contacts || []) {
|
||||||
|
const existing = await db.contacts.get(contact.id)
|
||||||
|
if (!existing || new Date(contact.syncedAt) > new Date(existing.syncedAt)) {
|
||||||
|
await db.contacts.put({ ...contact, syncedAt: contact.syncedAt })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update weight logs
|
||||||
|
for (const weight of data.weightLogs || []) {
|
||||||
|
const existing = await db.weightLogs.get(weight.id)
|
||||||
|
if (!existing || new Date(weight.syncedAt) > new Date(existing.syncedAt)) {
|
||||||
|
await db.weightLogs.put({ ...weight, syncedAt: weight.syncedAt })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update milestones
|
||||||
|
for (const milestone of data.milestones || []) {
|
||||||
|
const existing = await db.milestones.get(milestone.id)
|
||||||
|
if (!existing || new Date(milestone.syncedAt) > new Date(existing.syncedAt)) {
|
||||||
|
await db.milestones.put({ ...milestone, syncedAt: milestone.syncedAt })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update caregiver tasks
|
||||||
|
for (const task of data.caregiverTasks || []) {
|
||||||
|
const existing = await db.caregiverTasks.get(task.id)
|
||||||
|
if (!existing || new Date(task.syncedAt) > new Date(existing.syncedAt)) {
|
||||||
|
await db.caregiverTasks.put({ ...task, syncedAt: task.syncedAt })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lab results
|
||||||
|
for (const lab of data.labResults || []) {
|
||||||
|
const existing = await db.labResults.get(lab.id)
|
||||||
|
if (!existing || new Date(lab.syncedAt) > new Date(existing.syncedAt)) {
|
||||||
|
await db.labResults.put({ ...lab, syncedAt: lab.syncedAt })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update sync cursor
|
// Update sync cursor
|
||||||
await db.syncMeta.put({
|
await db.syncMeta.put({
|
||||||
id: workspaceId,
|
id: workspaceId,
|
||||||
@@ -181,7 +232,7 @@ export async function pushChanges(workspaceId: string): Promise<boolean> {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
ops: ops.map((op) => ({
|
ops: ops.map((op: SyncOp) => ({
|
||||||
id: op.id,
|
id: op.id,
|
||||||
type: op.type,
|
type: op.type,
|
||||||
entityType: op.entityType,
|
entityType: op.entityType,
|
||||||
@@ -199,30 +250,30 @@ export async function pushChanges(workspaceId: string): Promise<boolean> {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
// Process results and remove successful ops from outbox
|
// Process results and remove successful ops from outbox
|
||||||
await db.transaction('rw', [db.outbox, db.appointments, db.notes, db.symptoms], async () => {
|
await db.transaction('rw', [db.outbox, db.appointments, db.notes, db.symptoms, db.temperatureLogs, db.contacts, db.weightLogs, db.milestones, db.caregiverTasks, db.labResults], async () => {
|
||||||
for (const result of data.results) {
|
for (const result of data.results) {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Find the op
|
// Find the op
|
||||||
const op = ops.find((o) => o.id === result.opId)
|
const op = ops.find((o: SyncOp) => o.id === result.opId)
|
||||||
if (op && op.entityId?.startsWith('temp_') && result.entityId) {
|
if (op && op.entityId?.startsWith('temp_') && result.entityId) {
|
||||||
// Update local entity with real ID
|
// Update local entity with real ID
|
||||||
if (op.entityType === 'APPOINTMENT') {
|
const entityTableMap: Record<string, { get: (id: string) => Promise<any>, delete: (id: string) => Promise<void>, put: (item: any) => Promise<any> }> = {
|
||||||
const local = await db.appointments.get(op.entityId)
|
APPOINTMENT: db.appointments as any,
|
||||||
|
NOTE: db.notes as any,
|
||||||
|
SYMPTOM: db.symptoms as any,
|
||||||
|
TEMPERATURE_LOG: db.temperatureLogs as any,
|
||||||
|
CONTACT: db.contacts as any,
|
||||||
|
WEIGHT_LOG: db.weightLogs as any,
|
||||||
|
MILESTONE: db.milestones as any,
|
||||||
|
CAREGIVER_TASK: db.caregiverTasks as any,
|
||||||
|
LAB_RESULT: db.labResults as any,
|
||||||
|
}
|
||||||
|
const table = entityTableMap[op.entityType]
|
||||||
|
if (table) {
|
||||||
|
const local = await table.get(op.entityId)
|
||||||
if (local) {
|
if (local) {
|
||||||
await db.appointments.delete(op.entityId)
|
await table.delete(op.entityId)
|
||||||
await db.appointments.put({ ...local, id: result.entityId })
|
await table.put({ ...(local as Record<string, unknown>), 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 })
|
|
||||||
}
|
|
||||||
} else if (op.entityType === 'SYMPTOM') {
|
|
||||||
const local = await db.symptoms.get(op.entityId)
|
|
||||||
if (local) {
|
|
||||||
await db.symptoms.delete(op.entityId)
|
|
||||||
await db.symptoms.put({ ...local, id: result.entityId })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -558,3 +609,346 @@ export async function deleteSymptom(symptom: LocalSymptom): Promise<void> {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TEMPERATURE LOG
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export async function logTemperature(
|
||||||
|
workspaceId: string,
|
||||||
|
data: { tempCelsius: number; method?: string; notes?: string; recordedAt?: string }
|
||||||
|
): Promise<LocalTemperatureLog> {
|
||||||
|
const id = generateTempId()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const temp: LocalTemperatureLog = {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
recordedAt: data.recordedAt || now,
|
||||||
|
tempCelsius: data.tempCelsius,
|
||||||
|
method: data.method || null,
|
||||||
|
notes: data.notes || null,
|
||||||
|
deletedAt: null,
|
||||||
|
version: 1,
|
||||||
|
syncedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.temperatureLogs.add(temp)
|
||||||
|
await addToOutbox({
|
||||||
|
workspaceId,
|
||||||
|
type: 'LOG_TEMP',
|
||||||
|
entityType: 'TEMPERATURE_LOG',
|
||||||
|
entityId: id,
|
||||||
|
data: { tempCelsius: data.tempCelsius, method: data.method, notes: data.notes, recordedAt: temp.recordedAt },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return temp
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTemperatureLog(temp: LocalTemperatureLog): Promise<void> {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await db.temperatureLogs.update(temp.id, { deletedAt: now, version: temp.version + 1, syncedAt: now })
|
||||||
|
await addToOutbox({
|
||||||
|
workspaceId: temp.workspaceId,
|
||||||
|
type: 'DELETE_TEMP',
|
||||||
|
entityType: 'TEMPERATURE_LOG',
|
||||||
|
entityId: temp.id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONTACT
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export async function createLocalContact(
|
||||||
|
workspaceId: string,
|
||||||
|
data: Omit<LocalContact, 'id' | 'workspaceId' | 'version' | 'syncedAt' | 'deletedAt'>
|
||||||
|
): Promise<LocalContact> {
|
||||||
|
const id = generateTempId()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const contact: LocalContact = {
|
||||||
|
...data,
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
deletedAt: null,
|
||||||
|
version: 1,
|
||||||
|
syncedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.contacts.add(contact)
|
||||||
|
await addToOutbox({
|
||||||
|
workspaceId,
|
||||||
|
type: 'CREATE_CONTACT',
|
||||||
|
entityType: 'CONTACT',
|
||||||
|
entityId: id,
|
||||||
|
data: {
|
||||||
|
name: data.name, role: data.role, category: data.category,
|
||||||
|
phone: data.phone, phone2: data.phone2, email: data.email,
|
||||||
|
address: data.address, hours: data.hours, notes: data.notes,
|
||||||
|
isEmergency: data.isEmergency, sortOrder: data.sortOrder,
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return contact
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLocalContact(
|
||||||
|
contact: LocalContact,
|
||||||
|
updates: Partial<Pick<LocalContact, 'name' | 'role' | 'category' | 'phone' | 'phone2' | 'email' | 'address' | 'hours' | 'notes' | 'isEmergency' | 'sortOrder'>>
|
||||||
|
): Promise<void> {
|
||||||
|
await db.contacts.update(contact.id, { ...updates, version: contact.version + 1, syncedAt: new Date().toISOString() })
|
||||||
|
await addToOutbox({
|
||||||
|
workspaceId: contact.workspaceId,
|
||||||
|
type: 'UPDATE_CONTACT',
|
||||||
|
entityType: 'CONTACT',
|
||||||
|
entityId: contact.id,
|
||||||
|
data: updates,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLocalContact(contact: LocalContact): Promise<void> {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await db.contacts.update(contact.id, { deletedAt: now, version: contact.version + 1, syncedAt: now })
|
||||||
|
await addToOutbox({
|
||||||
|
workspaceId: contact.workspaceId,
|
||||||
|
type: 'DELETE_CONTACT',
|
||||||
|
entityType: 'CONTACT',
|
||||||
|
entityId: contact.id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// WEIGHT LOG
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export async function logWeight(
|
||||||
|
workspaceId: string,
|
||||||
|
data: { weightKg: number; notes?: string; recordedAt?: string }
|
||||||
|
): Promise<LocalWeightLog> {
|
||||||
|
const id = generateTempId()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const weight: LocalWeightLog = {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
recordedAt: data.recordedAt || now,
|
||||||
|
weightKg: data.weightKg,
|
||||||
|
notes: data.notes || null,
|
||||||
|
deletedAt: null,
|
||||||
|
version: 1,
|
||||||
|
syncedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.weightLogs.add(weight)
|
||||||
|
await addToOutbox({
|
||||||
|
workspaceId,
|
||||||
|
type: 'LOG_WEIGHT',
|
||||||
|
entityType: 'WEIGHT_LOG',
|
||||||
|
entityId: id,
|
||||||
|
data: { weightKg: data.weightKg, notes: data.notes, recordedAt: weight.recordedAt },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return weight
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWeightLog(weight: LocalWeightLog): Promise<void> {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await db.weightLogs.update(weight.id, { deletedAt: now, version: weight.version + 1, syncedAt: now })
|
||||||
|
await addToOutbox({
|
||||||
|
workspaceId: weight.workspaceId,
|
||||||
|
type: 'DELETE_WEIGHT',
|
||||||
|
entityType: 'WEIGHT_LOG',
|
||||||
|
entityId: weight.id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MILESTONE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export async function createLocalMilestone(
|
||||||
|
workspaceId: string,
|
||||||
|
data: Omit<LocalMilestone, 'id' | 'workspaceId' | 'version' | 'syncedAt' | 'deletedAt'>
|
||||||
|
): Promise<LocalMilestone> {
|
||||||
|
const id = generateTempId()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const milestone: LocalMilestone = {
|
||||||
|
...data,
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
deletedAt: null,
|
||||||
|
version: 1,
|
||||||
|
syncedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.milestones.add(milestone)
|
||||||
|
await addToOutbox({
|
||||||
|
workspaceId,
|
||||||
|
type: 'CREATE_MILESTONE',
|
||||||
|
entityType: 'MILESTONE',
|
||||||
|
entityId: id,
|
||||||
|
data: {
|
||||||
|
type: data.type, title: data.title, description: data.description,
|
||||||
|
plannedDate: data.plannedDate, actualDate: data.actualDate,
|
||||||
|
status: data.status, notes: data.notes,
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return milestone
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLocalMilestone(
|
||||||
|
milestone: LocalMilestone,
|
||||||
|
updates: Partial<Pick<LocalMilestone, 'type' | 'title' | 'description' | 'plannedDate' | 'actualDate' | 'status' | 'notes'>>
|
||||||
|
): Promise<void> {
|
||||||
|
await db.milestones.update(milestone.id, { ...updates, version: milestone.version + 1, syncedAt: new Date().toISOString() })
|
||||||
|
await addToOutbox({
|
||||||
|
workspaceId: milestone.workspaceId,
|
||||||
|
type: 'UPDATE_MILESTONE',
|
||||||
|
entityType: 'MILESTONE',
|
||||||
|
entityId: milestone.id,
|
||||||
|
data: updates,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLocalMilestone(milestone: LocalMilestone): Promise<void> {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await db.milestones.update(milestone.id, { deletedAt: now, version: milestone.version + 1, syncedAt: now })
|
||||||
|
await addToOutbox({
|
||||||
|
workspaceId: milestone.workspaceId,
|
||||||
|
type: 'DELETE_MILESTONE',
|
||||||
|
entityType: 'MILESTONE',
|
||||||
|
entityId: milestone.id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CAREGIVER TASK
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export async function createLocalTask(
|
||||||
|
workspaceId: string,
|
||||||
|
data: Omit<LocalCaregiverTask, 'id' | 'workspaceId' | 'version' | 'syncedAt' | 'deletedAt' | 'completedAt'>
|
||||||
|
): Promise<LocalCaregiverTask> {
|
||||||
|
const id = generateTempId()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const task: LocalCaregiverTask = {
|
||||||
|
...data,
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
completedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
|
version: 1,
|
||||||
|
syncedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.caregiverTasks.add(task)
|
||||||
|
await addToOutbox({
|
||||||
|
workspaceId,
|
||||||
|
type: 'CREATE_TASK',
|
||||||
|
entityType: 'CAREGIVER_TASK',
|
||||||
|
entityId: id,
|
||||||
|
data: {
|
||||||
|
title: data.title, description: data.description, category: data.category,
|
||||||
|
priority: data.priority, status: data.status, assignedToId: data.assignedToId,
|
||||||
|
dueDate: data.dueDate,
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLocalTask(
|
||||||
|
task: LocalCaregiverTask,
|
||||||
|
updates: Partial<Pick<LocalCaregiverTask, 'title' | 'description' | 'category' | 'priority' | 'status' | 'assignedToId' | 'dueDate'>>
|
||||||
|
): Promise<void> {
|
||||||
|
await db.caregiverTasks.update(task.id, { ...updates, version: task.version + 1, syncedAt: new Date().toISOString() })
|
||||||
|
await addToOutbox({
|
||||||
|
workspaceId: task.workspaceId,
|
||||||
|
type: 'UPDATE_TASK',
|
||||||
|
entityType: 'CAREGIVER_TASK',
|
||||||
|
entityId: task.id,
|
||||||
|
data: updates,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeLocalTask(task: LocalCaregiverTask): Promise<void> {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await db.caregiverTasks.update(task.id, { status: 'DONE', completedAt: now, version: task.version + 1, syncedAt: now })
|
||||||
|
await addToOutbox({
|
||||||
|
workspaceId: task.workspaceId,
|
||||||
|
type: 'COMPLETE_TASK',
|
||||||
|
entityType: 'CAREGIVER_TASK',
|
||||||
|
entityId: task.id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLocalTask(task: LocalCaregiverTask): Promise<void> {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await db.caregiverTasks.update(task.id, { deletedAt: now, version: task.version + 1, syncedAt: now })
|
||||||
|
await addToOutbox({
|
||||||
|
workspaceId: task.workspaceId,
|
||||||
|
type: 'DELETE_TASK',
|
||||||
|
entityType: 'CAREGIVER_TASK',
|
||||||
|
entityId: task.id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// LAB RESULT
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export async function createLocalLabResult(
|
||||||
|
workspaceId: string,
|
||||||
|
data: Omit<LocalLabResult, 'id' | 'workspaceId' | 'version' | 'syncedAt' | 'deletedAt'>
|
||||||
|
): Promise<LocalLabResult> {
|
||||||
|
const id = generateTempId()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const lab: LocalLabResult = {
|
||||||
|
...data,
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
deletedAt: null,
|
||||||
|
version: 1,
|
||||||
|
syncedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.labResults.add(lab)
|
||||||
|
await addToOutbox({
|
||||||
|
workspaceId,
|
||||||
|
type: 'CREATE_LAB',
|
||||||
|
entityType: 'LAB_RESULT',
|
||||||
|
entityId: id,
|
||||||
|
data: {
|
||||||
|
testDate: data.testDate, panelName: data.panelName,
|
||||||
|
labName: data.labName, results: data.results, notes: data.notes,
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return lab
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLocalLabResult(lab: LocalLabResult): Promise<void> {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await db.labResults.update(lab.id, { deletedAt: now, version: lab.version + 1, syncedAt: now })
|
||||||
|
await addToOutbox({
|
||||||
|
workspaceId: lab.workspaceId,
|
||||||
|
type: 'DELETE_LAB',
|
||||||
|
entityType: 'LAB_RESULT',
|
||||||
|
entityId: lab.id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
387
src/lib/validation/schemas.test.ts
Normal file
387
src/lib/validation/schemas.test.ts
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
temperatureLogSchema,
|
||||||
|
contactSchema,
|
||||||
|
weightLogSchema,
|
||||||
|
milestoneSchema,
|
||||||
|
caregiverTaskSchema,
|
||||||
|
labResultSchema,
|
||||||
|
labMarkerSchema,
|
||||||
|
medicalDocumentSchema,
|
||||||
|
interactionCheckSchema,
|
||||||
|
} from './schemas'
|
||||||
|
|
||||||
|
describe('temperatureLogSchema', () => {
|
||||||
|
it('accepts valid temperature log', () => {
|
||||||
|
const result = temperatureLogSchema.safeParse({
|
||||||
|
tempCelsius: 37.2,
|
||||||
|
method: 'oral',
|
||||||
|
notes: 'Feeling warm',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts temperature without optional fields', () => {
|
||||||
|
const result = temperatureLogSchema.safeParse({ tempCelsius: 36.5 })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects temperature below 30°C', () => {
|
||||||
|
const result = temperatureLogSchema.safeParse({ tempCelsius: 29.9 })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects temperature above 45°C', () => {
|
||||||
|
const result = temperatureLogSchema.safeParse({ tempCelsius: 45.1 })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid method', () => {
|
||||||
|
const result = temperatureLogSchema.safeParse({
|
||||||
|
tempCelsius: 37.0,
|
||||||
|
method: 'rectal',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts null method', () => {
|
||||||
|
const result = temperatureLogSchema.safeParse({
|
||||||
|
tempCelsius: 37.0,
|
||||||
|
method: null,
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('contactSchema', () => {
|
||||||
|
const validContact = {
|
||||||
|
name: 'Dr. Smith',
|
||||||
|
role: 'Oncologist',
|
||||||
|
category: 'ONCOLOGY',
|
||||||
|
phone: '+61 2 1234 5678',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts valid contact', () => {
|
||||||
|
const result = contactSchema.safeParse(validContact)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires name', () => {
|
||||||
|
const result = contactSchema.safeParse({ ...validContact, name: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires phone', () => {
|
||||||
|
const result = contactSchema.safeParse({ ...validContact, phone: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid category', () => {
|
||||||
|
const result = contactSchema.safeParse({ ...validContact, category: 'INVALID' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts all valid categories', () => {
|
||||||
|
const categories = ['ONCOLOGY', 'HOSPITAL', 'PHARMACY', 'INSURANCE', 'FAMILY', 'OTHER']
|
||||||
|
for (const category of categories) {
|
||||||
|
const result = contactSchema.safeParse({ ...validContact, category })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates email format when provided', () => {
|
||||||
|
const result = contactSchema.safeParse({ ...validContact, email: 'not-an-email' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts valid email', () => {
|
||||||
|
const result = contactSchema.safeParse({ ...validContact, email: 'dr@clinic.com' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults isEmergency to false', () => {
|
||||||
|
const result = contactSchema.safeParse(validContact)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.isEmergency).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('weightLogSchema', () => {
|
||||||
|
it('accepts valid weight', () => {
|
||||||
|
const result = weightLogSchema.safeParse({ weightKg: 65.5 })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects weight below 1kg', () => {
|
||||||
|
const result = weightLogSchema.safeParse({ weightKg: 0.5 })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects weight above 500kg', () => {
|
||||||
|
const result = weightLogSchema.safeParse({ weightKg: 501 })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts optional notes', () => {
|
||||||
|
const result = weightLogSchema.safeParse({
|
||||||
|
weightKg: 70,
|
||||||
|
notes: 'After breakfast',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('milestoneSchema', () => {
|
||||||
|
const validMilestone = {
|
||||||
|
type: 'CHEMO_CYCLE',
|
||||||
|
title: 'Cycle 4 - Carboplatin',
|
||||||
|
plannedDate: '2026-03-15T09:00:00.000Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts valid milestone', () => {
|
||||||
|
const result = milestoneSchema.safeParse(validMilestone)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires title', () => {
|
||||||
|
const result = milestoneSchema.safeParse({ ...validMilestone, title: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires plannedDate as valid datetime', () => {
|
||||||
|
const result = milestoneSchema.safeParse({ ...validMilestone, plannedDate: 'not-a-date' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts all valid types', () => {
|
||||||
|
const types = ['CHEMO_CYCLE', 'SURGERY', 'RADIATION', 'SCAN', 'CONSULTATION', 'OTHER']
|
||||||
|
for (const type of types) {
|
||||||
|
const result = milestoneSchema.safeParse({ ...validMilestone, type })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults status to SCHEDULED', () => {
|
||||||
|
const result = milestoneSchema.safeParse(validMilestone)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.status).toBe('SCHEDULED')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts all valid statuses', () => {
|
||||||
|
const statuses = ['SCHEDULED', 'COMPLETED', 'DELAYED', 'CANCELLED']
|
||||||
|
for (const status of statuses) {
|
||||||
|
const result = milestoneSchema.safeParse({ ...validMilestone, status })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('caregiverTaskSchema', () => {
|
||||||
|
const validTask = {
|
||||||
|
title: 'Pick up prescription',
|
||||||
|
category: 'ERRANDS',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts valid task', () => {
|
||||||
|
const result = caregiverTaskSchema.safeParse(validTask)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires title', () => {
|
||||||
|
const result = caregiverTaskSchema.safeParse({ ...validTask, title: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults priority to NORMAL', () => {
|
||||||
|
const result = caregiverTaskSchema.safeParse(validTask)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.priority).toBe('NORMAL')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults status to TODO', () => {
|
||||||
|
const result = caregiverTaskSchema.safeParse(validTask)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.status).toBe('TODO')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts all categories', () => {
|
||||||
|
const cats = ['MEDICAL', 'ERRANDS', 'MEALS', 'EMOTIONAL', 'OTHER']
|
||||||
|
for (const category of cats) {
|
||||||
|
const result = caregiverTaskSchema.safeParse({ ...validTask, category })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts all priority levels', () => {
|
||||||
|
const pris = ['URGENT', 'HIGH', 'NORMAL', 'LOW']
|
||||||
|
for (const priority of pris) {
|
||||||
|
const result = caregiverTaskSchema.safeParse({ ...validTask, priority })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts optional dueDate', () => {
|
||||||
|
const result = caregiverTaskSchema.safeParse({
|
||||||
|
...validTask,
|
||||||
|
dueDate: '2026-03-15T09:00:00.000Z',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid dueDate format', () => {
|
||||||
|
const result = caregiverTaskSchema.safeParse({
|
||||||
|
...validTask,
|
||||||
|
dueDate: 'next tuesday',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('labMarkerSchema', () => {
|
||||||
|
it('accepts valid marker', () => {
|
||||||
|
const result = labMarkerSchema.safeParse({
|
||||||
|
marker: 'WBC',
|
||||||
|
value: 7.5,
|
||||||
|
unit: 'K/uL',
|
||||||
|
refMin: 4.5,
|
||||||
|
refMax: 11.0,
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts marker without reference ranges', () => {
|
||||||
|
const result = labMarkerSchema.safeParse({
|
||||||
|
marker: 'WBC',
|
||||||
|
value: 7.5,
|
||||||
|
unit: 'K/uL',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires marker name', () => {
|
||||||
|
const result = labMarkerSchema.safeParse({
|
||||||
|
marker: '',
|
||||||
|
value: 7.5,
|
||||||
|
unit: 'K/uL',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts flag values', () => {
|
||||||
|
const flags = ['LOW', 'HIGH', 'CRITICAL_LOW', 'CRITICAL_HIGH']
|
||||||
|
for (const flag of flags) {
|
||||||
|
const result = labMarkerSchema.safeParse({
|
||||||
|
marker: 'WBC',
|
||||||
|
value: 3.0,
|
||||||
|
unit: 'K/uL',
|
||||||
|
flag,
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid flag', () => {
|
||||||
|
const result = labMarkerSchema.safeParse({
|
||||||
|
marker: 'WBC',
|
||||||
|
value: 3.0,
|
||||||
|
unit: 'K/uL',
|
||||||
|
flag: 'VERY_HIGH',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('labResultSchema', () => {
|
||||||
|
const validResult = {
|
||||||
|
testDate: '2026-03-01T09:00:00.000Z',
|
||||||
|
panelName: 'Complete Blood Count',
|
||||||
|
results: [
|
||||||
|
{ marker: 'WBC', value: 7.5, unit: 'K/uL', refMin: 4.5, refMax: 11.0 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts valid lab result', () => {
|
||||||
|
const result = labResultSchema.safeParse(validResult)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires at least one marker', () => {
|
||||||
|
const result = labResultSchema.safeParse({ ...validResult, results: [] })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires testDate', () => {
|
||||||
|
const { testDate, ...noDate } = validResult
|
||||||
|
const result = labResultSchema.safeParse(noDate)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires panelName', () => {
|
||||||
|
const result = labResultSchema.safeParse({ ...validResult, panelName: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('medicalDocumentSchema', () => {
|
||||||
|
it('accepts valid document metadata', () => {
|
||||||
|
const result = medicalDocumentSchema.safeParse({
|
||||||
|
title: 'Blood work Feb 2026',
|
||||||
|
category: 'LAB_REPORT',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires title', () => {
|
||||||
|
const result = medicalDocumentSchema.safeParse({
|
||||||
|
title: '',
|
||||||
|
category: 'LAB_REPORT',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts all valid categories', () => {
|
||||||
|
const cats = ['LAB_REPORT', 'SCAN', 'INSURANCE', 'ID_CARD', 'PRESCRIPTION', 'OTHER']
|
||||||
|
for (const category of cats) {
|
||||||
|
const result = medicalDocumentSchema.safeParse({ title: 'Test', category })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid category', () => {
|
||||||
|
const result = medicalDocumentSchema.safeParse({
|
||||||
|
title: 'Test',
|
||||||
|
category: 'XRAY',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('interactionCheckSchema', () => {
|
||||||
|
it('accepts valid list of medication IDs', () => {
|
||||||
|
const result = interactionCheckSchema.safeParse({
|
||||||
|
medicationIds: ['clxxxxxxxxxxxxxxxxxxxxxxxxx', 'clyyyyyyyyyyyyyyyyyyyyyyyy'],
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires at least 2 IDs', () => {
|
||||||
|
const result = interactionCheckSchema.safeParse({
|
||||||
|
medicationIds: ['clxxxxxxxxxxxxxxxxxxxxxxxxx'],
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects more than 20 IDs', () => {
|
||||||
|
const ids = Array.from({ length: 21 }, (_, i) => `cl${'x'.repeat(24)}${i}`)
|
||||||
|
const result = interactionCheckSchema.safeParse({ medicationIds: ids })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -128,8 +128,20 @@ export const syncQuerySchema = z.object({
|
|||||||
|
|
||||||
export const syncOpSchema = z.object({
|
export const syncOpSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
type: z.enum(['CREATE', 'UPDATE', 'DELETE', 'TAKE_DOSE', 'UNDO_DOSE', 'MARK_ASKED', 'UNMARK_ASKED', 'REFILL', 'LOG_SYMPTOM', 'DELETE_SYMPTOM']),
|
type: z.enum([
|
||||||
entityType: z.enum(['APPOINTMENT', 'MEDICATION', 'NOTE', 'DOSE_LOG', 'SYMPTOM']),
|
'CREATE', 'UPDATE', 'DELETE', 'TAKE_DOSE', 'UNDO_DOSE', 'MARK_ASKED', 'UNMARK_ASKED', 'REFILL',
|
||||||
|
'LOG_SYMPTOM', 'DELETE_SYMPTOM',
|
||||||
|
'LOG_TEMP', 'DELETE_TEMP',
|
||||||
|
'CREATE_CONTACT', 'UPDATE_CONTACT', 'DELETE_CONTACT',
|
||||||
|
'LOG_WEIGHT', 'DELETE_WEIGHT',
|
||||||
|
'CREATE_MILESTONE', 'UPDATE_MILESTONE', 'DELETE_MILESTONE',
|
||||||
|
'CREATE_TASK', 'UPDATE_TASK', 'DELETE_TASK', 'COMPLETE_TASK',
|
||||||
|
'CREATE_LAB', 'UPDATE_LAB', 'DELETE_LAB',
|
||||||
|
]),
|
||||||
|
entityType: z.enum([
|
||||||
|
'APPOINTMENT', 'MEDICATION', 'NOTE', 'DOSE_LOG', 'SYMPTOM',
|
||||||
|
'TEMPERATURE_LOG', 'CONTACT', 'WEIGHT_LOG', 'MILESTONE', 'CAREGIVER_TASK', 'LAB_RESULT',
|
||||||
|
]),
|
||||||
entityId: z.string().optional(),
|
entityId: z.string().optional(),
|
||||||
data: z.record(z.unknown()).optional(),
|
data: z.record(z.unknown()).optional(),
|
||||||
timestamp: z.number(),
|
timestamp: z.number(),
|
||||||
@@ -166,6 +178,129 @@ export const medicationWithRefillSchema = medicationSchema.extend({
|
|||||||
lastRefillDate: z.string().datetime().nullable().optional(),
|
lastRefillDate: z.string().datetime().nullable().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TEMPERATURE LOG
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const temperatureLogSchema = z.object({
|
||||||
|
tempCelsius: z.number().min(30).max(45),
|
||||||
|
method: z.enum(['oral', 'forehead', 'ear', 'armpit']).nullable().optional(),
|
||||||
|
notes: z.string().max(500).nullable().optional(),
|
||||||
|
recordedAt: z.string().datetime().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONTACT DIRECTORY
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const contactCategoryEnum = z.enum(['ONCOLOGY', 'HOSPITAL', 'PHARMACY', 'INSURANCE', 'FAMILY', 'OTHER'])
|
||||||
|
|
||||||
|
export const contactSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required').max(200),
|
||||||
|
role: z.string().min(1, 'Role is required').max(100),
|
||||||
|
category: contactCategoryEnum,
|
||||||
|
phone: z.string().min(1, 'Phone is required').max(50),
|
||||||
|
phone2: z.string().max(50).nullable().optional(),
|
||||||
|
email: z.string().email().max(200).nullable().optional(),
|
||||||
|
address: z.string().max(500).nullable().optional(),
|
||||||
|
hours: z.string().max(200).nullable().optional(),
|
||||||
|
notes: z.string().max(1000).nullable().optional(),
|
||||||
|
isEmergency: z.boolean().default(false),
|
||||||
|
sortOrder: z.number().int().min(0).default(0),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// WEIGHT LOG
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const weightLogSchema = z.object({
|
||||||
|
weightKg: z.number().min(1).max(500),
|
||||||
|
notes: z.string().max(500).nullable().optional(),
|
||||||
|
recordedAt: z.string().datetime().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TREATMENT MILESTONE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const milestoneTypeEnum = z.enum(['CHEMO_CYCLE', 'SURGERY', 'RADIATION', 'SCAN', 'CONSULTATION', 'OTHER'])
|
||||||
|
export const milestoneStatusEnum = z.enum(['SCHEDULED', 'COMPLETED', 'DELAYED', 'CANCELLED'])
|
||||||
|
|
||||||
|
export const milestoneSchema = z.object({
|
||||||
|
type: milestoneTypeEnum,
|
||||||
|
title: z.string().min(1, 'Title is required').max(200),
|
||||||
|
description: z.string().max(1000).nullable().optional(),
|
||||||
|
plannedDate: z.string().datetime(),
|
||||||
|
actualDate: z.string().datetime().nullable().optional(),
|
||||||
|
status: milestoneStatusEnum.default('SCHEDULED'),
|
||||||
|
notes: z.string().max(2000).nullable().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CAREGIVER TASK
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const taskCategoryEnum = z.enum(['MEDICAL', 'ERRANDS', 'MEALS', 'EMOTIONAL', 'OTHER'])
|
||||||
|
export const taskPriorityEnum = z.enum(['URGENT', 'HIGH', 'NORMAL', 'LOW'])
|
||||||
|
export const taskStatusEnum = z.enum(['TODO', 'IN_PROGRESS', 'DONE', 'CANCELLED'])
|
||||||
|
|
||||||
|
export const caregiverTaskSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required').max(200),
|
||||||
|
description: z.string().max(2000).nullable().optional(),
|
||||||
|
category: taskCategoryEnum,
|
||||||
|
priority: taskPriorityEnum.default('NORMAL'),
|
||||||
|
status: taskStatusEnum.default('TODO'),
|
||||||
|
assignedToId: z.string().cuid().nullable().optional(),
|
||||||
|
dueDate: z.string().datetime().nullable().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// LAB RESULT
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const labFlagEnum = z.enum(['LOW', 'HIGH', 'CRITICAL_LOW', 'CRITICAL_HIGH'])
|
||||||
|
|
||||||
|
export const labMarkerSchema = z.object({
|
||||||
|
marker: z.string().min(1).max(50),
|
||||||
|
value: z.number(),
|
||||||
|
unit: z.string().max(20),
|
||||||
|
refMin: z.number().nullable().optional(),
|
||||||
|
refMax: z.number().nullable().optional(),
|
||||||
|
flag: labFlagEnum.nullable().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const labResultSchema = z.object({
|
||||||
|
testDate: z.string().datetime(),
|
||||||
|
panelName: z.string().min(1).max(200),
|
||||||
|
labName: z.string().max(200).nullable().optional(),
|
||||||
|
results: z.array(labMarkerSchema).min(1),
|
||||||
|
notes: z.string().max(2000).nullable().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MEDICAL DOCUMENT (metadata only — file sent as multipart)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const documentCategoryEnum = z.enum(['LAB_REPORT', 'SCAN', 'INSURANCE', 'ID_CARD', 'PRESCRIPTION', 'OTHER'])
|
||||||
|
|
||||||
|
export const medicalDocumentSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required').max(200),
|
||||||
|
category: documentCategoryEnum,
|
||||||
|
dateTaken: z.string().datetime().nullable().optional(),
|
||||||
|
expiryDate: z.string().datetime().nullable().optional(),
|
||||||
|
notes: z.string().max(1000).nullable().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// DRUG INTERACTION CHECK
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const interactionCheckSchema = z.object({
|
||||||
|
medicationIds: z.array(z.string().cuid()).min(2).max(20),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const interactionSeverityEnum = z.enum(['MINOR', 'MODERATE', 'MAJOR', 'CONTRAINDICATED'])
|
||||||
|
|
||||||
// Type exports
|
// Type exports
|
||||||
export type LoginInput = z.infer<typeof loginSchema>
|
export type LoginInput = z.infer<typeof loginSchema>
|
||||||
export type RegisterInput = z.infer<typeof registerSchema>
|
export type RegisterInput = z.infer<typeof registerSchema>
|
||||||
@@ -182,3 +317,21 @@ export type NoteInput = z.infer<typeof noteSchema>
|
|||||||
export type SymptomInput = z.infer<typeof symptomSchema>
|
export type SymptomInput = z.infer<typeof symptomSchema>
|
||||||
export type SymptomType = z.infer<typeof symptomTypeEnum>
|
export type SymptomType = z.infer<typeof symptomTypeEnum>
|
||||||
export type SyncOp = z.infer<typeof syncOpSchema>
|
export type SyncOp = z.infer<typeof syncOpSchema>
|
||||||
|
export type TemperatureLogInput = z.infer<typeof temperatureLogSchema>
|
||||||
|
export type ContactInput = z.infer<typeof contactSchema>
|
||||||
|
export type ContactCategory = z.infer<typeof contactCategoryEnum>
|
||||||
|
export type WeightLogInput = z.infer<typeof weightLogSchema>
|
||||||
|
export type MilestoneInput = z.infer<typeof milestoneSchema>
|
||||||
|
export type MilestoneType = z.infer<typeof milestoneTypeEnum>
|
||||||
|
export type MilestoneStatus = z.infer<typeof milestoneStatusEnum>
|
||||||
|
export type CaregiverTaskInput = z.infer<typeof caregiverTaskSchema>
|
||||||
|
export type TaskCategory = z.infer<typeof taskCategoryEnum>
|
||||||
|
export type TaskPriority = z.infer<typeof taskPriorityEnum>
|
||||||
|
export type TaskStatus = z.infer<typeof taskStatusEnum>
|
||||||
|
export type LabMarker = z.infer<typeof labMarkerSchema>
|
||||||
|
export type LabFlag = z.infer<typeof labFlagEnum>
|
||||||
|
export type LabResultInput = z.infer<typeof labResultSchema>
|
||||||
|
export type MedicalDocumentInput = z.infer<typeof medicalDocumentSchema>
|
||||||
|
export type DocumentCategory = z.infer<typeof documentCategoryEnum>
|
||||||
|
export type InteractionCheckInput = z.infer<typeof interactionCheckSchema>
|
||||||
|
export type InteractionSeverity = z.infer<typeof interactionSeverityEnum>
|
||||||
|
|||||||
@@ -9,55 +9,93 @@ const config: Config = {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
// Calm, healing palette
|
// Warm Sanctuary - Primary: Soft sage green (healing, calm)
|
||||||
primary: {
|
primary: {
|
||||||
50: '#f0f9f4',
|
50: '#f4f7f4',
|
||||||
100: '#dcf1e4',
|
100: '#e3ebe3',
|
||||||
200: '#bbe3cc',
|
200: '#c5d9c5',
|
||||||
300: '#8dcda8',
|
300: '#9bbf9b',
|
||||||
400: '#5bb17f',
|
400: '#729f72',
|
||||||
500: '#3a9563',
|
500: '#528252',
|
||||||
600: '#2a784e',
|
600: '#3f663f',
|
||||||
700: '#235f40',
|
700: '#345234',
|
||||||
800: '#1f4c35',
|
800: '#2b412b',
|
||||||
900: '#1b3f2d',
|
900: '#243624',
|
||||||
950: '#0d2319',
|
950: '#121f12',
|
||||||
},
|
},
|
||||||
|
// Warm neutrals - cream, stone, warm gray
|
||||||
|
cream: {
|
||||||
|
50: '#fdfcfa',
|
||||||
|
100: '#faf7f2',
|
||||||
|
200: '#f5efe6',
|
||||||
|
300: '#ede3d5',
|
||||||
|
400: '#e0d0bc',
|
||||||
|
500: '#d4bfa3',
|
||||||
|
600: '#c4a882',
|
||||||
|
700: '#a88b65',
|
||||||
|
800: '#8a7255',
|
||||||
|
900: '#705d47',
|
||||||
|
950: '#3d3226',
|
||||||
|
},
|
||||||
|
// Secondary: Warm stone gray (sophisticated, grounded)
|
||||||
secondary: {
|
secondary: {
|
||||||
50: '#f5f7fa',
|
50: '#f8f7f6',
|
||||||
100: '#ebeef3',
|
100: '#f0eeeb',
|
||||||
200: '#d2dae5',
|
200: '#e0dcd5',
|
||||||
300: '#aab9ce',
|
300: '#ccc6bb',
|
||||||
400: '#7c93b3',
|
400: '#b5ad9f',
|
||||||
500: '#5c769a',
|
500: '#a09484',
|
||||||
600: '#485e80',
|
600: '#857a6d',
|
||||||
700: '#3b4d68',
|
700: '#6d6359',
|
||||||
800: '#344257',
|
800: '#5a524a',
|
||||||
900: '#2f3a4a',
|
900: '#4a443f',
|
||||||
950: '#1f2631',
|
950: '#262320',
|
||||||
},
|
},
|
||||||
|
// Accent: Terracotta (warmth, energy, gentle urgency)
|
||||||
accent: {
|
accent: {
|
||||||
50: '#fef6ee',
|
50: '#fdf8f6',
|
||||||
100: '#fdebd7',
|
100: '#faeee9',
|
||||||
200: '#fad3ae',
|
200: '#f5dcd2',
|
||||||
300: '#f6b37b',
|
300: '#ecc0b0',
|
||||||
400: '#f18946',
|
400: '#e09b82',
|
||||||
500: '#ed6b22',
|
500: '#d67b58',
|
||||||
600: '#de5118',
|
600: '#c6603e',
|
||||||
700: '#b83c16',
|
700: '#a54c30',
|
||||||
800: '#93311a',
|
800: '#88402b',
|
||||||
900: '#772b18',
|
900: '#703728',
|
||||||
950: '#40130b',
|
950: '#3d1a11',
|
||||||
},
|
},
|
||||||
background: '#fafbfc',
|
// Alert red (emergency - softened)
|
||||||
|
alert: {
|
||||||
|
50: '#fdf5f4',
|
||||||
|
100: '#fce8e6',
|
||||||
|
200: '#f9d5d2',
|
||||||
|
300: '#f4b7b1',
|
||||||
|
400: '#ec8c85',
|
||||||
|
500: '#e0635a',
|
||||||
|
600: '#c9453d',
|
||||||
|
700: '#a83832',
|
||||||
|
800: '#8b322e',
|
||||||
|
900: '#742f2c',
|
||||||
|
950: '#3e1514',
|
||||||
|
},
|
||||||
|
// Semantic aliases
|
||||||
|
background: '#faf7f2',
|
||||||
surface: '#ffffff',
|
surface: '#ffffff',
|
||||||
muted: '#f1f5f9',
|
muted: '#f0eeeb',
|
||||||
border: '#e2e8f0',
|
border: '#e0dcd5',
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
// Playfair Display for elegant headings
|
||||||
|
display: ['Playfair Display', 'Georgia', 'serif'],
|
||||||
|
// Source Sans 3 for warm, readable body text
|
||||||
|
sans: ['Source Sans 3', 'system-ui', 'sans-serif'],
|
||||||
},
|
},
|
||||||
fontSize: {
|
fontSize: {
|
||||||
|
'display-xl': ['3.5rem', { lineHeight: '1.1', letterSpacing: '-0.02em' }],
|
||||||
|
'display-lg': ['2.75rem', { lineHeight: '1.15', letterSpacing: '-0.02em' }],
|
||||||
|
'display-md': ['2.25rem', { lineHeight: '1.2', letterSpacing: '-0.01em' }],
|
||||||
|
'display-sm': ['1.875rem', { lineHeight: '1.25', letterSpacing: '-0.01em' }],
|
||||||
// Large text mode sizes
|
// Large text mode sizes
|
||||||
'lg-base': '1.125rem',
|
'lg-base': '1.125rem',
|
||||||
'lg-lg': '1.25rem',
|
'lg-lg': '1.25rem',
|
||||||
@@ -67,17 +105,61 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
spacing: {
|
spacing: {
|
||||||
// Touch-friendly spacing
|
// Touch-friendly spacing
|
||||||
'touch': '44px',
|
'touch': '48px',
|
||||||
'touch-lg': '56px',
|
'touch-lg': '60px',
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
'card': '16px',
|
'card': '20px',
|
||||||
'button': '12px',
|
'card-lg': '28px',
|
||||||
|
'button': '14px',
|
||||||
|
'pill': '9999px',
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
'card': '0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04)',
|
// Soft, warm shadows
|
||||||
'card-hover': '0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05)',
|
'card': '0 2px 8px -2px rgba(93, 82, 70, 0.06), 0 4px 16px -4px rgba(93, 82, 70, 0.04)',
|
||||||
'button': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
'card-hover': '0 8px 24px -4px rgba(93, 82, 70, 0.08), 0 4px 12px -2px rgba(93, 82, 70, 0.05)',
|
||||||
|
'button': '0 1px 3px rgba(93, 82, 70, 0.08)',
|
||||||
|
'button-hover': '0 4px 12px -2px rgba(93, 82, 70, 0.15)',
|
||||||
|
'soft': '0 2px 16px rgba(93, 82, 70, 0.06)',
|
||||||
|
'elevated': '0 8px 32px -4px rgba(93, 82, 70, 0.1)',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
// Gentle, breathing animations
|
||||||
|
'breathe': 'breathe 4s ease-in-out infinite',
|
||||||
|
'fade-up': 'fadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards',
|
||||||
|
'fade-in': 'fadeIn 0.4s ease-out forwards',
|
||||||
|
'scale-in': 'scaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards',
|
||||||
|
'slide-up': 'slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards',
|
||||||
|
'pulse-soft': 'pulseSoft 2s ease-in-out infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
breathe: {
|
||||||
|
'0%, 100%': { opacity: '1', transform: 'scale(1)' },
|
||||||
|
'50%': { opacity: '0.9', transform: 'scale(1.02)' },
|
||||||
|
},
|
||||||
|
fadeUp: {
|
||||||
|
'0%': { opacity: '0', transform: 'translateY(20px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
scaleIn: {
|
||||||
|
'0%': { opacity: '0', transform: 'scale(0.95)' },
|
||||||
|
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
'0%': { opacity: '0', transform: 'translateY(30px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
pulseSoft: {
|
||||||
|
'0%, 100%': { opacity: '1' },
|
||||||
|
'50%': { opacity: '0.7' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transitionTimingFunction: {
|
||||||
|
'sanctuary': 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export default defineConfig({
|
|||||||
environment: 'node',
|
environment: 'node',
|
||||||
globals: true,
|
globals: true,
|
||||||
include: ['**/*.test.ts'],
|
include: ['**/*.test.ts'],
|
||||||
|
env: {
|
||||||
|
TZ: 'Australia/Perth',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Reference in New Issue
Block a user