mirror of
https://github.com/Tony0410/nextstep.git
synced 2026-05-25 05:41:39 +08:00
Initial commit: Next Step health management app
A calm, reliable app to help manage appointments, medications, and notes for chemo patients and their families. Features: - Today dashboard with next appointment and medications due - Medication tracking with multiple schedule types (fixed times, interval, weekdays, PRN) - One-tap dose logging with 5-minute undo window - Questions for doctor tracking - Family sharing with workspace model and invite links - Offline-first with IndexedDB and sync - Docker Compose deployment with Tailscale Funnel support Tech stack: - Next.js 14 (App Router) + TypeScript + Tailwind CSS - PostgreSQL + Prisma - Argon2 password hashing + session cookies - Dexie.js for IndexedDB Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
275
src/app/(app)/meds/new/page.tsx
Normal file
275
src/app/(app)/meds/new/page.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Input, Textarea, Select, Card, showToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { useApp } from '../../provider'
|
||||
|
||||
type ScheduleType = 'FIXED_TIMES' | 'INTERVAL' | 'WEEKDAYS' | 'PRN'
|
||||
|
||||
const scheduleTypeOptions = [
|
||||
{ value: 'FIXED_TIMES', label: 'Fixed times daily' },
|
||||
{ value: 'INTERVAL', label: 'Every X hours' },
|
||||
{ value: 'WEEKDAYS', label: 'Specific days of week' },
|
||||
{ value: 'PRN', label: 'As needed (PRN)' },
|
||||
]
|
||||
|
||||
const weekdays = [
|
||||
{ value: 0, label: 'Sun' },
|
||||
{ value: 1, label: 'Mon' },
|
||||
{ value: 2, label: 'Tue' },
|
||||
{ value: 3, label: 'Wed' },
|
||||
{ value: 4, label: 'Thu' },
|
||||
{ value: 5, label: 'Fri' },
|
||||
{ value: 6, label: 'Sat' },
|
||||
]
|
||||
|
||||
export default function NewMedicationPage() {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace, refreshData } = useApp()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [instructions, setInstructions] = useState('')
|
||||
const [scheduleType, setScheduleType] = useState<ScheduleType>('FIXED_TIMES')
|
||||
|
||||
// Fixed times
|
||||
const [times, setTimes] = useState(['08:00'])
|
||||
|
||||
// Interval
|
||||
const [intervalHours, setIntervalHours] = useState(8)
|
||||
const [startTime, setStartTime] = useState('08:00')
|
||||
|
||||
// Weekdays
|
||||
const [selectedDays, setSelectedDays] = useState<number[]>([1, 3, 5])
|
||||
const [weekdayTime, setWeekdayTime] = useState('09:00')
|
||||
|
||||
// PRN
|
||||
const [minHoursBetween, setMinHoursBetween] = useState(4)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const addTime = () => {
|
||||
setTimes([...times, '12:00'])
|
||||
}
|
||||
|
||||
const removeTime = (index: number) => {
|
||||
setTimes(times.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const updateTime = (index: number, value: string) => {
|
||||
const newTimes = [...times]
|
||||
newTimes[index] = value
|
||||
setTimes(newTimes)
|
||||
}
|
||||
|
||||
const toggleDay = (day: number) => {
|
||||
if (selectedDays.includes(day)) {
|
||||
setSelectedDays(selectedDays.filter((d) => d !== day))
|
||||
} else {
|
||||
setSelectedDays([...selectedDays, day].sort())
|
||||
}
|
||||
}
|
||||
|
||||
const buildScheduleData = () => {
|
||||
switch (scheduleType) {
|
||||
case 'FIXED_TIMES':
|
||||
return { type: 'FIXED_TIMES', times }
|
||||
case 'INTERVAL':
|
||||
return { type: 'INTERVAL', hours: intervalHours, startTime }
|
||||
case 'WEEKDAYS':
|
||||
return { type: 'WEEKDAYS', days: selectedDays, time: weekdayTime }
|
||||
case 'PRN':
|
||||
return { type: 'PRN', minHoursBetween }
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/medications`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
instructions: instructions || null,
|
||||
scheduleType,
|
||||
scheduleData: buildScheduleData(),
|
||||
active: true,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Failed to add medication')
|
||||
}
|
||||
|
||||
await refreshData()
|
||||
showToast('Medication added', 'success')
|
||||
router.push('/meds')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Something went wrong')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="New Medication" showBack backHref="/meds" />
|
||||
<PageContainer className="pt-4">
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<Input
|
||||
label="Medication Name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Paracetamol 500mg"
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Instructions (optional)"
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="e.g., Take with food"
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Schedule Type"
|
||||
value={scheduleType}
|
||||
onChange={(e) => setScheduleType(e.target.value as ScheduleType)}
|
||||
options={scheduleTypeOptions}
|
||||
/>
|
||||
|
||||
{/* Schedule-specific options */}
|
||||
{scheduleType === 'FIXED_TIMES' && (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-secondary-700">
|
||||
Times to take
|
||||
</label>
|
||||
{times.map((time, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => updateTime(index, e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
{times.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => removeTime(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="secondary" onClick={addTime} size="sm">
|
||||
+ Add time
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scheduleType === 'INTERVAL' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Every (hours)"
|
||||
type="number"
|
||||
min={1}
|
||||
max={72}
|
||||
value={intervalHours}
|
||||
onChange={(e) => setIntervalHours(parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
<Input
|
||||
label="Starting at"
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scheduleType === 'WEEKDAYS' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Days
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{weekdays.map((day) => (
|
||||
<button
|
||||
key={day.value}
|
||||
type="button"
|
||||
onClick={() => toggleDay(day.value)}
|
||||
className={`px-3 py-2 rounded-button text-sm font-medium transition-colors ${
|
||||
selectedDays.includes(day.value)
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-muted text-secondary-600 hover:bg-secondary-200'
|
||||
}`}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label="Time"
|
||||
type="time"
|
||||
value={weekdayTime}
|
||||
onChange={(e) => setWeekdayTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scheduleType === 'PRN' && (
|
||||
<Input
|
||||
label="Minimum hours between doses"
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={72}
|
||||
step={0.5}
|
||||
value={minHoursBetween}
|
||||
onChange={(e) => setMinHoursBetween(parseFloat(e.target.value) || 4)}
|
||||
helperText="Shows 'Available' when enough time has passed since last dose"
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" fullWidth loading={loading}>
|
||||
Save Medication
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user