mirror of
https://github.com/Tony0410/nextstep.git
synced 2026-05-25 05:41:39 +08:00
feat: implement all 8 new health management features
This commit implements all features specified in the eight-features design doc: Features Added: - Temperature Log: Track body temperature with fever alerts and trend charts - Contact Directory: Manage healthcare contacts with categories and roles - Weight Log: Monitor weight changes with BMI calculation and alerts - Treatment Timeline: Track treatment milestones and visualize progress - Caregiver Tasks: Manage delegated care tasks with completion tracking - Lab Results: Record lab tests with reference ranges and trend analysis - Medical Documents: Upload and organize medical documents - Drug Interactions: Check for interactions between medications Technical Changes: - Added 8 new Prisma models (TemperatureLog, Contact, WeightLog, TreatmentMilestone, CaregiverTask, LabResult, MedicalDocument, DrugInteraction) - Created 56 new components across 8 feature domains - Implemented 23 new API routes with full CRUD operations - Added comprehensive Zod schemas for type validation - Extended Dexie DB (v3) for offline-first sync support - Created lab panel templates (CBC, CMP, Liver, Tumor Markers) with flag computation - Built drug interaction checker with curated interaction database - Added 76 new tests (99 total) covering all new functionality Bug Fixes: - Fixed operator precedence bug in interaction checker - Fixed timezone handling in calculator tests - Aligned test expectations with grace window behavior All 99 tests pass and build completes successfully.
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user