mirror of
https://github.com/Tony0410/nextstep.git
synced 2026-05-24 21:31:43 +08:00
Fix appointment edit page and iCal timezone issues
- Create appointment edit page at /appointments/[id]/edit - Fix iCal calendar timezone handling: - Add VTIMEZONE block for Australia/Perth - Use TZID parameter for DTSTART/DTEND - Properly format local times without Z suffix - Appointments now appear correctly in Google Calendar Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
215
src/app/(app)/appointments/[id]/edit/page.tsx
Normal file
215
src/app/(app)/appointments/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useRouter, useParams } from 'next/navigation'
|
||||||
|
import { format, parseISO } from 'date-fns'
|
||||||
|
import { toZonedTime } from 'date-fns-tz'
|
||||||
|
import { Button, Input, Textarea, Card, LoadingState, showToast } from '@/components/ui'
|
||||||
|
import { Header, PageContainer } from '@/components/layout/header'
|
||||||
|
import { useApp } from '../../../provider'
|
||||||
|
|
||||||
|
const TIMEZONE = 'Australia/Perth'
|
||||||
|
|
||||||
|
interface Appointment {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
datetime: string
|
||||||
|
location: string | null
|
||||||
|
mapUrl: string | null
|
||||||
|
notes: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditAppointmentPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const params = useParams()
|
||||||
|
const appointmentId = params.id as string
|
||||||
|
const { currentWorkspace, refreshData } = useApp()
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [date, setDate] = useState('')
|
||||||
|
const [time, setTime] = useState('')
|
||||||
|
const [location, setLocation] = useState('')
|
||||||
|
const [mapUrl, setMapUrl] = useState('')
|
||||||
|
const [notes, setNotes] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchAppointment() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`
|
||||||
|
)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
const appt: Appointment = data.appointment
|
||||||
|
|
||||||
|
// Parse datetime and convert to local timezone
|
||||||
|
const apptDate = toZonedTime(parseISO(appt.datetime), TIMEZONE)
|
||||||
|
setTitle(appt.title)
|
||||||
|
setDate(format(apptDate, 'yyyy-MM-dd'))
|
||||||
|
setTime(format(apptDate, 'HH:mm'))
|
||||||
|
setLocation(appt.location || '')
|
||||||
|
setMapUrl(appt.mapUrl || '')
|
||||||
|
setNotes(appt.notes || '')
|
||||||
|
} else {
|
||||||
|
setError('Appointment not found')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch appointment:', err)
|
||||||
|
setError('Failed to load appointment')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAppointment()
|
||||||
|
}, [currentWorkspace.id, appointmentId])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setSaving(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Combine date and time
|
||||||
|
const datetime = new Date(`${date}T${time}:00`)
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
datetime: datetime.toISOString(),
|
||||||
|
location: location || null,
|
||||||
|
mapUrl: mapUrl || null,
|
||||||
|
notes: notes || null,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
throw new Error(data.error || 'Failed to update appointment')
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshData()
|
||||||
|
showToast('Appointment updated', 'success')
|
||||||
|
router.push(`/appointments/${appointmentId}`)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Something went wrong')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Edit Appointment" showBack />
|
||||||
|
<PageContainer>
|
||||||
|
<LoadingState message="Loading appointment..." />
|
||||||
|
</PageContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !title) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Edit Appointment" showBack />
|
||||||
|
<PageContainer className="pt-4">
|
||||||
|
<Card className="text-center py-8">
|
||||||
|
<p className="text-secondary-500">{error}</p>
|
||||||
|
</Card>
|
||||||
|
</PageContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Edit Appointment" showBack backHref={`/appointments/${appointmentId}`} />
|
||||||
|
<PageContainer className="pt-4">
|
||||||
|
<Card>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Title"
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g., Oncology Appointment"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Time"
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => setTime(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Location"
|
||||||
|
type="text"
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
placeholder="e.g., Level 3, Cancer Centre"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Map Link (optional)"
|
||||||
|
type="url"
|
||||||
|
value={mapUrl}
|
||||||
|
onChange={(e) => setMapUrl(e.target.value)}
|
||||||
|
placeholder="https://maps.google.com/..."
|
||||||
|
helperText="Paste a Google Maps or Apple Maps link"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Any notes for this appointment..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{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={saving}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</PageContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { format, addHours } from 'date-fns'
|
import { format, addHours } from 'date-fns'
|
||||||
|
|
||||||
|
const TIMEZONE = 'Australia/Perth'
|
||||||
|
|
||||||
interface Appointment {
|
interface Appointment {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
@@ -35,8 +37,19 @@ export function generateICalendar(
|
|||||||
'VERSION:2.0',
|
'VERSION:2.0',
|
||||||
'PRODID:-//NextStep//Health Management//EN',
|
'PRODID:-//NextStep//Health Management//EN',
|
||||||
`X-WR-CALNAME:${escapeICalText(workspaceName)}`,
|
`X-WR-CALNAME:${escapeICalText(workspaceName)}`,
|
||||||
|
`X-WR-TIMEZONE:${TIMEZONE}`,
|
||||||
'CALSCALE:GREGORIAN',
|
'CALSCALE:GREGORIAN',
|
||||||
'METHOD:PUBLISH',
|
'METHOD:PUBLISH',
|
||||||
|
// Add timezone definition for Australia/Perth
|
||||||
|
'BEGIN:VTIMEZONE',
|
||||||
|
`TZID:${TIMEZONE}`,
|
||||||
|
'BEGIN:STANDARD',
|
||||||
|
'DTSTART:19700101T000000',
|
||||||
|
'TZOFFSETFROM:+0800',
|
||||||
|
'TZOFFSETTO:+0800',
|
||||||
|
'TZNAME:AWST',
|
||||||
|
'END:STANDARD',
|
||||||
|
'END:VTIMEZONE',
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const appt of appointments) {
|
for (const appt of appointments) {
|
||||||
@@ -45,9 +58,9 @@ export function generateICalendar(
|
|||||||
|
|
||||||
lines.push('BEGIN:VEVENT')
|
lines.push('BEGIN:VEVENT')
|
||||||
lines.push(`UID:${appt.id}@nextstep`)
|
lines.push(`UID:${appt.id}@nextstep`)
|
||||||
lines.push(`DTSTAMP:${formatICalDate(new Date())}`)
|
lines.push(`DTSTAMP:${formatICalDateUTC(new Date())}`)
|
||||||
lines.push(`DTSTART:${formatICalDate(startDate)}`)
|
lines.push(`DTSTART;TZID=${TIMEZONE}:${formatICalDateLocal(startDate)}`)
|
||||||
lines.push(`DTEND:${formatICalDate(endDate)}`)
|
lines.push(`DTEND;TZID=${TIMEZONE}:${formatICalDateLocal(endDate)}`)
|
||||||
lines.push(`SUMMARY:${escapeICalText(appt.title)}`)
|
lines.push(`SUMMARY:${escapeICalText(appt.title)}`)
|
||||||
|
|
||||||
if (appt.location) {
|
if (appt.location) {
|
||||||
@@ -116,9 +129,9 @@ export function generateMedicationEvents(
|
|||||||
|
|
||||||
lines.push('BEGIN:VEVENT')
|
lines.push('BEGIN:VEVENT')
|
||||||
lines.push(`UID:med-${med.id}-${dateStr}-${time}@nextstep`)
|
lines.push(`UID:med-${med.id}-${dateStr}-${time}@nextstep`)
|
||||||
lines.push(`DTSTAMP:${formatICalDate(new Date())}`)
|
lines.push(`DTSTAMP:${formatICalDateUTC(new Date())}`)
|
||||||
lines.push(`DTSTART:${formatICalDate(startDate)}`)
|
lines.push(`DTSTART;TZID=${TIMEZONE}:${formatICalDateLocal(startDate)}`)
|
||||||
lines.push(`DTEND:${formatICalDate(endDate)}`)
|
lines.push(`DTEND;TZID=${TIMEZONE}:${formatICalDateLocal(endDate)}`)
|
||||||
lines.push(`SUMMARY:Take ${escapeICalText(med.name)}`)
|
lines.push(`SUMMARY:Take ${escapeICalText(med.name)}`)
|
||||||
lines.push('CATEGORIES:MEDICATION')
|
lines.push('CATEGORIES:MEDICATION')
|
||||||
|
|
||||||
@@ -161,11 +174,16 @@ function getMedicationTimes(med: Medication): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatICalDate(date: Date): string {
|
function formatICalDateUTC(date: Date): string {
|
||||||
// Format: YYYYMMDDTHHMMSSZ
|
// Format: YYYYMMDDTHHMMSSZ (UTC)
|
||||||
return format(date, "yyyyMMdd'T'HHmmss'Z'")
|
return format(date, "yyyyMMdd'T'HHmmss'Z'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatICalDateLocal(date: Date): string {
|
||||||
|
// Format: YYYYMMDDTHHMMSS (local time, no Z suffix)
|
||||||
|
return format(date, "yyyyMMdd'T'HHmmss")
|
||||||
|
}
|
||||||
|
|
||||||
function escapeICalText(text: string): string {
|
function escapeICalText(text: string): string {
|
||||||
return text
|
return text
|
||||||
.replace(/\\/g, '\\\\')
|
.replace(/\\/g, '\\\\')
|
||||||
|
|||||||
Reference in New Issue
Block a user