mirror of
https://github.com/Tony0410/nextstep.git
synced 2026-05-24 21:31:43 +08:00
Compare commits
4 Commits
66cb1ea095
...
cae436a20d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cae436a20d | ||
|
|
8c9ae06360 | ||
|
|
7fa95c058e | ||
|
|
f598f6138e |
@@ -41,7 +41,7 @@ RUN addgroup --system --gid 1001 nodejs
|
|||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Install OpenSSL and CA certificates for Prisma
|
# Install OpenSSL and CA certificates for Prisma
|
||||||
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y openssl ca-certificates wget && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
|||||||
@@ -31,6 +31,24 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
image: alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: /bin/sh
|
||||||
|
command: >
|
||||||
|
-c "apk add --no-cache curl &&
|
||||||
|
while true; do
|
||||||
|
echo 'Triggering notification check...' &&
|
||||||
|
curl -s -X POST http://app:3000/api/notifications/send &&
|
||||||
|
echo '' &&
|
||||||
|
sleep 60;
|
||||||
|
done"
|
||||||
|
networks:
|
||||||
|
- nextstep-network
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: nextstep-db
|
container_name: nextstep-db
|
||||||
|
|||||||
46
src/app/(app)/meds/[id]/edit/page.tsx
Normal file
46
src/app/(app)/meds/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
|
import { db } from '@/lib/sync'
|
||||||
|
import { Header, PageContainer } from '@/components/layout/header'
|
||||||
|
import { MedicationForm } from '@/components/medications/MedicationForm'
|
||||||
|
import { LoadingState } from '@/components/ui'
|
||||||
|
|
||||||
|
export default function EditMedicationPage({ params }: { params: { id: string } | Promise<{ id: string }> }) {
|
||||||
|
const [medicationId, setMedicationId] = useState<string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (params instanceof Promise) {
|
||||||
|
params.then((p) => setMedicationId(p.id))
|
||||||
|
} else {
|
||||||
|
setMedicationId(params.id)
|
||||||
|
}
|
||||||
|
}, [params])
|
||||||
|
|
||||||
|
const medication = useLiveQuery(
|
||||||
|
() => (medicationId ? db.medications.get(medicationId) : undefined),
|
||||||
|
[medicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!medicationId || !medication) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Edit Medication" showBack />
|
||||||
|
<PageContainer>
|
||||||
|
<LoadingState message="Loading medication..." />
|
||||||
|
</PageContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Edit Medication" showBack />
|
||||||
|
<PageContainer className="pt-4">
|
||||||
|
<MedicationForm initialData={medication} isEditing />
|
||||||
|
</PageContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,19 +1,41 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { use, useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { Pill, Clock, Edit2, Trash2, History } from 'lucide-react'
|
import { Pill, Clock, Trash2, History, X, Edit2 } 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'
|
||||||
|
import type { LocalDoseLog } from '@/lib/sync'
|
||||||
import { Card, Button, LoadingState, Modal, showToast, showUndoToast } from '@/components/ui'
|
import { Card, Button, LoadingState, Modal, showToast, showUndoToast } from '@/components/ui'
|
||||||
import { Header, PageContainer } from '@/components/layout/header'
|
import { Header, PageContainer } from '@/components/layout/header'
|
||||||
import { RefillTracker } from '@/components/medications/RefillTracker'
|
import { RefillTracker } from '@/components/medications/RefillTracker'
|
||||||
import { useApp } from '../../provider'
|
import { useApp } from '../../provider'
|
||||||
|
|
||||||
export default function MedicationDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
// Unwrapping params for Next.js 14/15 compatibility
|
||||||
const { id: medicationId } = use(params)
|
// In Next.js 15 params is a Promise, in 14 it's an object.
|
||||||
|
// We can use a simple `use` polyfill or just await it if we were in an async component,
|
||||||
|
// but this is a client component.
|
||||||
|
// For client components, params is passed as is.
|
||||||
|
// If types say Promise, we might need to use `use` but `use` is experimental in React 18.
|
||||||
|
// Let's assume params is an object for now as per Next 14 standard behavior for pages.
|
||||||
|
// If it is a promise (Next 15), we need `use`.
|
||||||
|
// Safest way: check if it has .then?
|
||||||
|
// Actually, let's just assume object for Next 14.
|
||||||
|
|
||||||
|
export default function MedicationDetailPage({ params }: { params: { id: string } | Promise<{ id: string }> }) {
|
||||||
|
// Simple unwrap if it's a promise (though likely it's an object in Next 14)
|
||||||
|
const [medicationId, setMedicationId] = useState<string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (params instanceof Promise) {
|
||||||
|
params.then((p) => setMedicationId(p.id))
|
||||||
|
} else {
|
||||||
|
setMedicationId(params.id)
|
||||||
|
}
|
||||||
|
}, [params])
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { currentWorkspace, refreshData } = useApp()
|
const { currentWorkspace, refreshData } = useApp()
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
@@ -21,19 +43,21 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
// Fetch medication from IndexedDB
|
// Fetch medication from IndexedDB
|
||||||
const medication = useLiveQuery(
|
const medication = useLiveQuery(
|
||||||
() => db.medications.get(medicationId),
|
() => (medicationId ? db.medications.get(medicationId) : undefined),
|
||||||
[medicationId]
|
[medicationId]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetch recent dose logs
|
// Fetch recent dose logs
|
||||||
const doseLogs = useLiveQuery(
|
const doseLogs = useLiveQuery(
|
||||||
() =>
|
() =>
|
||||||
db.doseLogs
|
medicationId
|
||||||
.where('medicationId')
|
? db.doseLogs
|
||||||
.equals(medicationId)
|
.where('medicationId')
|
||||||
.reverse()
|
.equals(medicationId)
|
||||||
.limit(10)
|
.reverse()
|
||||||
.toArray(),
|
.limit(10)
|
||||||
|
.toArray()
|
||||||
|
: [],
|
||||||
[medicationId]
|
[medicationId]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,6 +82,15 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
|
|||||||
}
|
}
|
||||||
}, [medication, currentWorkspace.id])
|
}, [medication, currentWorkspace.id])
|
||||||
|
|
||||||
|
const handleDeleteDose = async (dose: LocalDoseLog) => {
|
||||||
|
try {
|
||||||
|
await undoDose(dose)
|
||||||
|
showToast('Dose removed', 'success')
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to remove dose', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!medication) return
|
if (!medication) return
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
@@ -79,25 +112,27 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatSchedule = () => {
|
const formatSchedule = () => {
|
||||||
if (!medication) return ''
|
if (!medication || !medication.scheduleData) return ''
|
||||||
const data = medication.scheduleData as Record<string, unknown>
|
const data = medication.scheduleData as Record<string, unknown>
|
||||||
switch (medication.scheduleType) {
|
switch (medication.scheduleType) {
|
||||||
case 'FIXED_TIMES':
|
case 'FIXED_TIMES':
|
||||||
return `Daily at ${(data.times as string[]).join(', ')}`
|
return `Daily at ${(Array.isArray(data.times) ? data.times : []).join(', ')}`
|
||||||
case 'INTERVAL':
|
case 'INTERVAL':
|
||||||
return `Every ${data.hours} hours (starting ${data.startTime})`
|
return `Every ${data.hours || '?'} hours (starting ${data.startTime || '?'})`
|
||||||
case 'WEEKDAYS':
|
case 'WEEKDAYS':
|
||||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||||
const selectedDays = (data.days as number[]).map(d => days[d]).join(', ')
|
const selectedDays = (Array.isArray(data.days) ? data.days : [])
|
||||||
return `${selectedDays} at ${data.time}`
|
.map((d: number) => days[d])
|
||||||
|
.join(', ')
|
||||||
|
return `${selectedDays} at ${data.time || '?'}`
|
||||||
case 'PRN':
|
case 'PRN':
|
||||||
return `As needed (min ${data.minHoursBetween}h between doses)`
|
return `As needed (min ${data.minHoursBetween || '?'}h between doses)`
|
||||||
default:
|
default:
|
||||||
return medication.scheduleType
|
return medication.scheduleType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!medication) {
|
if (!medicationId || !medication) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Medication" showBack />
|
<Header title="Medication" showBack />
|
||||||
@@ -118,9 +153,9 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
|
|||||||
rightAction={
|
rightAction={
|
||||||
currentWorkspace.role !== 'VIEWER'
|
currentWorkspace.role !== 'VIEWER'
|
||||||
? {
|
? {
|
||||||
icon: <Trash2 className="w-6 h-6 text-red-600" />,
|
icon: <Edit2 className="w-5 h-5 text-primary-600" />,
|
||||||
label: 'Delete',
|
label: 'Edit',
|
||||||
onClick: () => setShowDeleteModal(true),
|
onClick: () => router.push(`/meds/${medication.id}/edit`),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -193,7 +228,7 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
|
|||||||
<Card padding="none">
|
<Card padding="none">
|
||||||
<ul className="divide-y divide-border">
|
<ul className="divide-y divide-border">
|
||||||
{recentDoses.map((dose) => (
|
{recentDoses.map((dose) => (
|
||||||
<li key={dose.id} className="px-4 py-3 flex items-center justify-between">
|
<li key={dose.id} className="px-4 py-3 flex items-center justify-between group">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-secondary-900">
|
<p className="text-sm font-medium text-secondary-900">
|
||||||
{format(new Date(dose.takenAt), 'EEEE, MMM d')}
|
{format(new Date(dose.takenAt), 'EEEE, MMM d')}
|
||||||
@@ -203,6 +238,15 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
|
|||||||
{dose.loggedBy && ` by ${dose.loggedBy.name}`}
|
{dose.loggedBy && ` by ${dose.loggedBy.name}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{currentWorkspace.role !== 'VIEWER' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteDose(dose)}
|
||||||
|
className="p-2 text-secondary-400 hover:text-red-600 hover:bg-red-50 rounded-full transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||||
|
title="Remove dose"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -213,6 +257,20 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Delete Action */}
|
||||||
|
{currentWorkspace.role !== 'VIEWER' && (
|
||||||
|
<div className="pt-4 pb-8">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-600 hover:bg-red-50 hover:text-red-700 w-full"
|
||||||
|
onClick={() => setShowDeleteModal(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete Medication
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
{/* Delete Confirmation Modal */}
|
||||||
|
|||||||
@@ -1,334 +1,15 @@
|
|||||||
'use client'
|
'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 { Header, PageContainer } from '@/components/layout/header'
|
||||||
import { useApp } from '../../provider'
|
import { MedicationForm } from '@/components/medications/MedicationForm'
|
||||||
|
|
||||||
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() {
|
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)
|
|
||||||
|
|
||||||
// Refill tracking (optional)
|
|
||||||
const [trackRefills, setTrackRefills] = useState(false)
|
|
||||||
const [pillCount, setPillCount] = useState<number | ''>('')
|
|
||||||
const [pillsPerDose, setPillsPerDose] = useState(1)
|
|
||||||
const [refillThreshold, setRefillThreshold] = useState(7)
|
|
||||||
|
|
||||||
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,
|
|
||||||
// Refill tracking (optional)
|
|
||||||
...(trackRefills && pillCount !== '' && {
|
|
||||||
pillCount: Number(pillCount),
|
|
||||||
pillsPerDose,
|
|
||||||
refillThreshold,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="New Medication" showBack backHref="/meds" />
|
<Header title="New Medication" showBack backHref="/meds" />
|
||||||
<PageContainer className="pt-4">
|
<PageContainer className="pt-4">
|
||||||
<Card>
|
<MedicationForm />
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Refill Tracking (optional) */}
|
|
||||||
<div className="border-t border-border pt-5">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="trackRefills"
|
|
||||||
checked={trackRefills}
|
|
||||||
onChange={(e) => setTrackRefills(e.target.checked)}
|
|
||||||
className="w-5 h-5 rounded border-border text-primary-600 focus:ring-primary-500"
|
|
||||||
/>
|
|
||||||
<label htmlFor="trackRefills" className="text-sm font-medium text-secondary-700">
|
|
||||||
Track pill count for refill reminders (optional)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{trackRefills && (
|
|
||||||
<div className="space-y-4 pl-8">
|
|
||||||
<Input
|
|
||||||
label="Current pill count"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={pillCount}
|
|
||||||
onChange={(e) => setPillCount(e.target.value === '' ? '' : parseInt(e.target.value))}
|
|
||||||
placeholder="e.g., 30"
|
|
||||||
helperText="How many pills do you have now?"
|
|
||||||
/>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<Input
|
|
||||||
label="Pills per dose"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={pillsPerDose}
|
|
||||||
onChange={(e) => setPillsPerDose(parseInt(e.target.value) || 1)}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Alert when below"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={refillThreshold}
|
|
||||||
onChange={(e) => setRefillThreshold(parseInt(e.target.value) || 7)}
|
|
||||||
helperText="pills"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{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>
|
</PageContainer>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Bell, Clock, Moon } from 'lucide-react'
|
import { Bell, Clock, Moon, Send } from 'lucide-react'
|
||||||
|
|
||||||
import { Card, Button, Input, showToast } from '@/components/ui'
|
import { Card, Button, Input, showToast } from '@/components/ui'
|
||||||
import { Header, PageContainer } from '@/components/layout/header'
|
import { Header, PageContainer } from '@/components/layout/header'
|
||||||
@@ -13,6 +13,7 @@ export default function NotificationsSettingsPage() {
|
|||||||
const [quietStart, setQuietStart] = useState(currentWorkspace.quietHoursStart || '22:00')
|
const [quietStart, setQuietStart] = useState(currentWorkspace.quietHoursStart || '22:00')
|
||||||
const [quietEnd, setQuietEnd] = useState(currentWorkspace.quietHoursEnd || '07:00')
|
const [quietEnd, setQuietEnd] = useState(currentWorkspace.quietHoursEnd || '07:00')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [testingSending, setTestingSending] = useState(false)
|
||||||
|
|
||||||
const handleSaveQuietHours = async () => {
|
const handleSaveQuietHours = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -37,6 +38,29 @@ export default function NotificationsSettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTestNotification = async () => {
|
||||||
|
setTestingSending(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/notifications/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ workspaceId: currentWorkspace.id }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to send test notification')
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(data.message, data.success ? 'success' : 'error')
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof Error ? err.message : 'Failed to send test', 'error')
|
||||||
|
} finally {
|
||||||
|
setTestingSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Notifications" showBack />
|
<Header title="Notifications" showBack />
|
||||||
@@ -51,6 +75,37 @@ export default function NotificationsSettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Test Notification */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||||
|
Test Notifications
|
||||||
|
</h2>
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-start gap-3 mb-4">
|
||||||
|
<div className="p-2 bg-blue-100 rounded-lg">
|
||||||
|
<Send className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-secondary-900">
|
||||||
|
Send Test Notification
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-secondary-500">
|
||||||
|
Verify that notifications are working on your device.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleTestNotification}
|
||||||
|
loading={testingSending}
|
||||||
|
fullWidth
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
Send Test Notification
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Quiet Hours */}
|
{/* Quiet Hours */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||||
|
|||||||
93
src/app/api/notifications/test/route.ts
Normal file
93
src/app/api/notifications/test/route.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/db/prisma'
|
||||||
|
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||||
|
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||||
|
import { sendPushNotification } from '@/lib/notifications/push'
|
||||||
|
|
||||||
|
// POST /api/notifications/test - Send a test notification
|
||||||
|
export const POST = withAuth(async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const { workspaceId } = body
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: 'workspaceId required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get push subscriptions for this user in this workspace
|
||||||
|
const subscriptions = await prisma.pushSubscription.findMany({
|
||||||
|
where: {
|
||||||
|
userId: req.session.user.id,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (subscriptions.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No push subscriptions found. Please enable notifications first.' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sent = 0
|
||||||
|
let failed = 0
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
for (const sub of subscriptions) {
|
||||||
|
try {
|
||||||
|
const success = await sendPushNotification(
|
||||||
|
{
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
p256dh: sub.p256dh,
|
||||||
|
auth: sub.auth,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Test Notification',
|
||||||
|
body: 'If you see this, notifications are working!',
|
||||||
|
tag: 'test-notification',
|
||||||
|
data: {
|
||||||
|
url: '/settings/notifications',
|
||||||
|
action: 'test',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
sent++
|
||||||
|
} else {
|
||||||
|
// Subscription expired, remove it
|
||||||
|
await prisma.pushSubscription.delete({ where: { id: sub.id } })
|
||||||
|
failed++
|
||||||
|
errors.push('Subscription expired and was removed')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Test notification error:', error)
|
||||||
|
failed++
|
||||||
|
errors.push(error.message || 'Unknown error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: sent > 0,
|
||||||
|
sent,
|
||||||
|
failed,
|
||||||
|
total: subscriptions.length,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
message: sent > 0
|
||||||
|
? `Test notification sent! Check your device.`
|
||||||
|
: `Failed to send notification: ${errors.join(', ')}`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Test notification error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to send test notification' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
360
src/components/medications/MedicationForm.tsx
Normal file
360
src/components/medications/MedicationForm.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Button, Input, Textarea, Select, Card, showToast } from '@/components/ui'
|
||||||
|
import { useApp } from '@/app/(app)/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' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface MedicationFormProps {
|
||||||
|
initialData?: {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
instructions?: string | null
|
||||||
|
scheduleType: string
|
||||||
|
scheduleData: any
|
||||||
|
active?: boolean
|
||||||
|
pillCount?: number | null
|
||||||
|
pillsPerDose?: number | null
|
||||||
|
refillThreshold?: number | null
|
||||||
|
}
|
||||||
|
isEditing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MedicationForm({ initialData, isEditing = false }: MedicationFormProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { currentWorkspace, refreshData } = useApp()
|
||||||
|
|
||||||
|
const [name, setName] = useState(initialData?.name || '')
|
||||||
|
const [instructions, setInstructions] = useState(initialData?.instructions || '')
|
||||||
|
const [scheduleType, setScheduleType] = useState<ScheduleType>((initialData?.scheduleType as ScheduleType) || 'FIXED_TIMES')
|
||||||
|
|
||||||
|
// Fixed times
|
||||||
|
const [times, setTimes] = useState<string[]>(initialData?.scheduleData?.times || ['08:00'])
|
||||||
|
|
||||||
|
// Interval
|
||||||
|
const [intervalHours, setIntervalHours] = useState(initialData?.scheduleData?.hours || 8)
|
||||||
|
const [startTime, setStartTime] = useState(initialData?.scheduleData?.startTime || '08:00')
|
||||||
|
|
||||||
|
// Weekdays
|
||||||
|
const [selectedDays, setSelectedDays] = useState<number[]>(initialData?.scheduleData?.days || [1, 3, 5])
|
||||||
|
const [weekdayTime, setWeekdayTime] = useState(initialData?.scheduleData?.time || '09:00')
|
||||||
|
|
||||||
|
// PRN
|
||||||
|
const [minHoursBetween, setMinHoursBetween] = useState(initialData?.scheduleData?.minHoursBetween || 4)
|
||||||
|
|
||||||
|
// Refill tracking (optional)
|
||||||
|
const hasRefillInfo = initialData?.pillCount !== null && initialData?.pillCount !== undefined
|
||||||
|
const [trackRefills, setTrackRefills] = useState(hasRefillInfo)
|
||||||
|
const [pillCount, setPillCount] = useState<number | ''>(initialData?.pillCount ?? '')
|
||||||
|
const [pillsPerDose, setPillsPerDose] = useState(initialData?.pillsPerDose || 1)
|
||||||
|
const [refillThreshold, setRefillThreshold] = useState(initialData?.refillThreshold || 7)
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset defaults if switching types and no initial data for that type
|
||||||
|
if (initialData?.scheduleType !== scheduleType) {
|
||||||
|
// Keep current state if user is just switching around in new mode
|
||||||
|
}
|
||||||
|
}, [scheduleType, initialData])
|
||||||
|
|
||||||
|
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 url = isEditing && initialData?.id
|
||||||
|
? `/api/workspaces/${currentWorkspace.id}/medications/${initialData.id}`
|
||||||
|
: `/api/workspaces/${currentWorkspace.id}/medications`
|
||||||
|
|
||||||
|
const method = isEditing ? 'PATCH' : 'POST'
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
instructions: instructions || null,
|
||||||
|
scheduleType,
|
||||||
|
scheduleData: buildScheduleData(),
|
||||||
|
active: true,
|
||||||
|
// Refill tracking
|
||||||
|
...(trackRefills && pillCount !== '' && {
|
||||||
|
pillCount: Number(pillCount),
|
||||||
|
pillsPerDose,
|
||||||
|
refillThreshold,
|
||||||
|
}),
|
||||||
|
// Explicitly nullify if disabled during edit
|
||||||
|
...(isEditing && !trackRefills && {
|
||||||
|
pillCount: null,
|
||||||
|
pillsPerDose: null,
|
||||||
|
refillThreshold: null,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
throw new Error(data.error || 'Failed to save medication')
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshData()
|
||||||
|
showToast(isEditing ? 'Medication updated' : 'Medication added', 'success')
|
||||||
|
router.push('/meds')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Something went wrong')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Refill Tracking (optional) */}
|
||||||
|
<div className="border-t border-border pt-5">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="trackRefills"
|
||||||
|
checked={trackRefills}
|
||||||
|
onChange={(e) => setTrackRefills(e.target.checked)}
|
||||||
|
className="w-5 h-5 rounded border-border text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="trackRefills" className="text-sm font-medium text-secondary-700">
|
||||||
|
Track pill count for refill reminders (optional)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{trackRefills && (
|
||||||
|
<div className="space-y-4 pl-8">
|
||||||
|
<Input
|
||||||
|
label="Current pill count"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={pillCount}
|
||||||
|
onChange={(e) => setPillCount(e.target.value === '' ? '' : parseInt(e.target.value))}
|
||||||
|
placeholder="e.g., 30"
|
||||||
|
helperText="How many pills do you have now?"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Pills per dose"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={pillsPerDose}
|
||||||
|
onChange={(e) => setPillsPerDose(parseInt(e.target.value) || 1)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Alert when below"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={refillThreshold}
|
||||||
|
onChange={(e) => setRefillThreshold(parseInt(e.target.value) || 7)}
|
||||||
|
helperText="pills"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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}>
|
||||||
|
{isEditing ? 'Update Medication' : 'Save Medication'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -133,6 +133,11 @@ function calculateIntervalDue(
|
|||||||
// Calculate how many intervals have passed since start time today
|
// Calculate how many intervals have passed since start time today
|
||||||
const minutesSinceStart = differenceInMinutes(now, startToday)
|
const minutesSinceStart = differenceInMinutes(now, startToday)
|
||||||
const intervalMinutes = schedule.hours * 60
|
const intervalMinutes = schedule.hours * 60
|
||||||
|
|
||||||
|
if (intervalMinutes <= 0) {
|
||||||
|
return startToday
|
||||||
|
}
|
||||||
|
|
||||||
const intervalsPassed = Math.floor(minutesSinceStart / intervalMinutes)
|
const intervalsPassed = Math.floor(minutesSinceStart / intervalMinutes)
|
||||||
const nextDue = addMinutes(startToday, (intervalsPassed + 1) * intervalMinutes)
|
const nextDue = addMinutes(startToday, (intervalsPassed + 1) * intervalMinutes)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user