import { useState, FormEvent } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import api, { getErrorMessage } from '@/lib/api'; import type { CalendarEvent, Location, RecurrenceRule } from '@/types'; import { useCalendars } from '@/hooks/useCalendars'; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter, SheetClose, } from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import LocationPicker from '@/components/ui/location-picker'; interface EventFormProps { event: CalendarEvent | null; templateData?: Partial | null; templateName?: string | null; initialStart?: string | null; initialEnd?: string | null; initialAllDay?: boolean; editScope?: 'this' | 'this_and_future' | null; onClose: () => void; } function toDateOnly(dt: string): string { if (!dt) return ''; return dt.split('T')[0]; } function toDatetimeLocal(dt: string, fallbackTime: string = '09:00'): string { if (!dt) return ''; if (dt.includes('T')) return dt.slice(0, 16); return `${dt}T${fallbackTime}`; } function formatForInput(dt: string, allDay: boolean, fallbackTime: string = '09:00'): string { if (!dt) return ''; return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime); } /** FullCalendar uses exclusive end dates for all-day events. Subtract 1 day for display. */ function adjustAllDayEndForDisplay(dateStr: string): string { if (!dateStr) return ''; const d = new Date(dateStr.split('T')[0] + 'T12:00:00'); d.setDate(d.getDate() - 1); const pad = (n: number) => n.toString().padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } /** Add 1 day to form end date before sending to API for all-day events. */ function adjustAllDayEndForSave(dateStr: string): string { if (!dateStr) return ''; const d = new Date(dateStr + 'T12:00:00'); d.setDate(d.getDate() + 1); const pad = (n: number) => n.toString().padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } // Python weekday: 0=Monday, 6=Sunday const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; function parseRecurrenceRule(raw?: string): RecurrenceRule | null { if (!raw) return null; try { return JSON.parse(raw); } catch { return null; } } function nowLocal(): string { const now = new Date(); const pad = (n: number) => n.toString().padStart(2, '0'); return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`; } function plusOneHour(dt: string): string { const d = new Date(dt); d.setHours(d.getHours() + 1); const pad = (n: number) => n.toString().padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } export default function EventForm({ event, templateData, templateName, initialStart, initialEnd, initialAllDay, editScope, onClose }: EventFormProps) { const queryClient = useQueryClient(); const { data: calendars = [] } = useCalendars(); // Merge template data as defaults for new events const source = event || templateData; const isAllDay = source?.all_day ?? initialAllDay ?? false; // Default to current time / +1 hour when creating a new event with no selection const defaultStart = nowLocal(); const defaultEnd = plusOneHour(defaultStart); const rawStart = event?.start_datetime || initialStart || defaultStart; const rawEnd = event?.end_datetime || initialEnd || defaultEnd; const defaultCalendar = calendars.find((c) => c.is_default); const initialCalendarId = source?.calendar_id?.toString() || defaultCalendar?.id?.toString() || ''; const isEditing = !!event?.id; // For all-day events, adjust end date for display (FullCalendar exclusive end) const displayEnd = isAllDay ? adjustAllDayEndForDisplay(rawEnd) : rawEnd; const [formData, setFormData] = useState({ title: source?.title || '', description: source?.description || '', start_datetime: formatForInput(rawStart, isAllDay, '09:00'), end_datetime: formatForInput(displayEnd, isAllDay, '10:00'), all_day: isAllDay, location_id: source?.location_id?.toString() || '', calendar_id: initialCalendarId, is_starred: source?.is_starred || false, }); const existingRule = parseRecurrenceRule(source?.recurrence_rule); const [recurrenceType, setRecurrenceType] = useState(existingRule?.type || ''); const [recurrenceInterval, setRecurrenceInterval] = useState(existingRule?.interval || 2); const [recurrenceWeekday, setRecurrenceWeekday] = useState(existingRule?.weekday ?? 1); const [recurrenceWeek, setRecurrenceWeek] = useState(existingRule?.week || 1); const [recurrenceDay, setRecurrenceDay] = useState(existingRule?.day || 1); const { data: locations = [] } = useQuery({ queryKey: ['locations'], queryFn: async () => { const { data } = await api.get('/locations'); return data; }, }); // Location picker state const existingLocation = locations.find((l) => l.id === source?.location_id); const [locationSearch, setLocationSearch] = useState(existingLocation?.name || ''); const selectableCalendars = calendars.filter((c) => !c.is_system); const buildRecurrenceRule = (): RecurrenceRule | null => { if (!recurrenceType) return null; switch (recurrenceType) { case 'every_n_days': return { type: 'every_n_days', interval: recurrenceInterval }; case 'weekly': // No weekday needed — backend derives it from the event's start date return { type: 'weekly' }; case 'monthly_nth_weekday': return { type: 'monthly_nth_weekday', week: recurrenceWeek, weekday: recurrenceWeekday }; case 'monthly_date': return { type: 'monthly_date', day: recurrenceDay }; default: return null; } }; const mutation = useMutation({ mutationFn: async (data: typeof formData) => { const rule = buildRecurrenceRule(); // Adjust end date for all-day events before save let endDt = data.end_datetime; if (data.all_day && endDt) { endDt = adjustAllDayEndForSave(endDt); } const payload: Record = { title: data.title, description: data.description, start_datetime: data.start_datetime, end_datetime: endDt, all_day: data.all_day, location_id: data.location_id ? parseInt(data.location_id) : null, calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null, is_starred: data.is_starred, recurrence_rule: rule, }; if (isEditing) { if (editScope) { payload.edit_scope = editScope; } const response = await api.put(`/events/${event!.id}`, payload); return response.data; } else { const response = await api.post('/events', payload); return response.data; } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['upcoming'] }); toast.success(isEditing ? 'Event updated' : 'Event created'); onClose(); }, onError: (error) => { toast.error(getErrorMessage(error, isEditing ? 'Failed to update event' : 'Failed to create event')); }, }); const deleteMutation = useMutation({ mutationFn: async () => { const params = editScope ? `?scope=${editScope}` : ''; await api.delete(`/events/${event?.id}${params}`); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['upcoming'] }); toast.success('Event deleted'); onClose(); }, onError: (error) => { toast.error(getErrorMessage(error, 'Failed to delete event')); }, }); const handleSubmit = (e: FormEvent) => { e.preventDefault(); mutation.mutate(formData); }; return ( {isEditing ? 'Edit Event' : templateName ? `Create Event from ${templateName} Template` : 'New Event'}
setFormData({ ...formData, title: e.target.value })} required />