mirror of
https://github.com/Tony0410/nextstep.git
synced 2026-05-25 22:01:39 +08:00
Compare commits
2 Commits
main
...
feature-up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb88288f4 | ||
|
|
f0f674945c |
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
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, Heart } 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'
|
||||||
@@ -65,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[]>([])
|
||||||
|
|
||||||
@@ -397,8 +434,82 @@ 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 className={`transition-all duration-700 delay-600 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
<section className={`transition-all duration-700 delay-[700ms] ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||||
<h2 className="font-display text-xl text-secondary-900 mb-4">Quick Note</h2>
|
<h2 className="font-display text-xl text-secondary-900 mb-4">Quick Note</h2>
|
||||||
<div className="section-warm">
|
<div className="section-warm">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
|||||||
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
|||||||
@@ -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