From 8945295e2aadb08638c5a2e3e419cf03858b381a Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 25 Feb 2026 22:43:06 +0800 Subject: [PATCH] Replace Sheet overlays with inline detail panels across all pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Calendar: move view selector left, inline EventDetailPanel with view/edit/create modes, fix resize on panel close, remove all Sheet/Dialog usage - Todos: add TodoDetailPanel with inline view/edit/create, replace CategoryFilterBar with shared component (drag-and-drop categories), 55/45 split layout - Reminders: add ReminderDetailPanel with inline view/edit/create, 55/45 split layout - Dashboard: all widget items now deep-link to destination page AND open the relevant item's detail panel (events, todos, reminders) - Fix TS errors: unused imports, undefined→null coalescing Co-Authored-By: Claude Opus 4.6 --- .../src/components/calendar/CalendarPage.tsx | 327 ++---- .../components/calendar/EventDetailPanel.tsx | 958 +++++++++++++++--- .../components/dashboard/CalendarWidget.tsx | 2 +- .../components/dashboard/CountdownWidget.tsx | 2 +- .../components/dashboard/DashboardPage.tsx | 2 +- .../src/components/dashboard/TodoWidget.tsx | 2 +- .../components/dashboard/UpcomingWidget.tsx | 6 +- .../reminders/ReminderDetailPanel.tsx | 467 +++++++++ .../components/reminders/RemindersPage.tsx | 207 ++-- .../src/components/todos/TodoDetailPanel.tsx | 539 ++++++++++ frontend/src/components/todos/TodosPage.tsx | 339 ++++--- 11 files changed, 2277 insertions(+), 574 deletions(-) create mode 100644 frontend/src/components/reminders/ReminderDetailPanel.tsx create mode 100644 frontend/src/components/todos/TodoDetailPanel.tsx diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index 99fd875..6331466 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -14,15 +14,9 @@ import { useCalendars } from '@/hooks/useCalendars'; import { useSettings } from '@/hooks/useSettings'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; import CalendarSidebar from './CalendarSidebar'; -import EventForm from './EventForm'; import EventDetailPanel from './EventDetailPanel'; +import type { CreateDefaults } from './EventDetailPanel'; type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay'; @@ -32,32 +26,20 @@ const viewLabels: Record = { timeGridDay: 'Day', }; -type ScopeAction = 'edit' | 'delete'; - export default function CalendarPage() { const queryClient = useQueryClient(); const location = useLocation(); const calendarRef = useRef(null); - const [showForm, setShowForm] = useState(false); - const [editingEvent, setEditingEvent] = useState(null); - const [selectedStart, setSelectedStart] = useState(null); - const [selectedEnd, setSelectedEnd] = useState(null); - const [selectedAllDay, setSelectedAllDay] = useState(false); const [currentView, setCurrentView] = useState('dayGridMonth'); const [calendarTitle, setCalendarTitle] = useState(''); - const [templateEvent, setTemplateEvent] = useState | null>(null); - const [templateName, setTemplateName] = useState(null); - const [eventSearch, setEventSearch] = useState(''); const [searchFocused, setSearchFocused] = useState(false); - const [selectedEventId, setSelectedEventId] = useState(null); - // Scope dialog state - const [scopeDialogOpen, setScopeDialogOpen] = useState(false); - const [scopeAction, setScopeAction] = useState('edit'); - const [scopeEvent, setScopeEvent] = useState(null); - const [activeEditScope, setActiveEditScope] = useState<'this' | 'this_and_future' | null>(null); + // Panel state + const [selectedEventId, setSelectedEventId] = useState(null); + const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed'); + const [createDefaults, setCreateDefaults] = useState(null); const { settings } = useSettings(); const { data: calendars = [] } = useCalendars(); @@ -81,13 +63,17 @@ export default function CalendarPage() { // Handle navigation state from dashboard useEffect(() => { - const state = location.state as { date?: string; view?: string } | null; - if (!state?.date) return; + const state = location.state as { date?: string; view?: string; eventId?: number } | null; + if (!state) return; const calApi = calendarRef.current?.getApi(); - if (!calApi) return; - calApi.gotoDate(state.date); - if (state.view) calApi.changeView(state.view); - // Clear state to prevent re-triggering + if (state.date && calApi) { + calApi.gotoDate(state.date); + if (state.view) calApi.changeView(state.view); + } + if (state.eventId) { + setSelectedEventId(state.eventId); + setPanelMode('view'); + } window.history.replaceState({}, ''); }, [location.state]); @@ -102,6 +88,16 @@ export default function CalendarPage() { return () => observer.disconnect(); }, []); + const panelOpen = panelMode !== 'closed'; + + // Resize calendar when panel opens/closes (CSS transition won't trigger ResizeObserver on inner div) + useEffect(() => { + const timeout = setTimeout(() => { + calendarRef.current?.getApi().updateSize(); + }, 320); + return () => clearTimeout(timeout); + }, [panelOpen]); + // Scroll wheel navigation in month view useEffect(() => { const el = calendarContainerRef.current; @@ -130,7 +126,6 @@ export default function CalendarPage() { }, }); - const panelOpen = selectedEventId !== null; const selectedEvent = useMemo( () => events.find((e) => e.id === selectedEventId) ?? null, [selectedEventId, events], @@ -140,7 +135,7 @@ export default function CalendarPage() { useEffect(() => { if (!panelOpen) return; const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') setSelectedEventId(null); + if (e.key === 'Escape') handlePanelClose(); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); @@ -204,24 +199,6 @@ export default function CalendarPage() { }, }); - const scopeDeleteMutation = useMutation({ - mutationFn: async ({ id, scope }: { id: number; scope: string }) => { - await api.delete(`/events/${id}?scope=${scope}`); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); - queryClient.invalidateQueries({ queryKey: ['dashboard'] }); - queryClient.invalidateQueries({ queryKey: ['upcoming'] }); - toast.success('Event(s) deleted'); - setScopeDialogOpen(false); - setScopeEvent(null); - setSelectedEventId(null); - }, - onError: (error) => { - toast.error(getErrorMessage(error, 'Failed to delete event')); - }, - }); - const filteredEvents = useMemo(() => { if (calendars.length === 0) return events; return events.filter((e) => visibleCalendarIds.has(e.calendar_id)); @@ -236,17 +213,22 @@ export default function CalendarPage() { }, [filteredEvents, eventSearch]); const handleSearchSelect = (event: CalendarEvent) => { - const api = calendarRef.current?.getApi(); - if (!api) return; + const calApi = calendarRef.current?.getApi(); + if (!calApi) return; const startDate = new Date(event.start_datetime); - api.gotoDate(startDate); + calApi.gotoDate(startDate); if (event.all_day) { - api.changeView('dayGridMonth'); + calApi.changeView('dayGridMonth'); } else { - api.changeView('timeGridDay'); + calApi.changeView('timeGridDay'); } setEventSearch(''); setSearchFocused(false); + // Also open the event in the panel + if (!event.is_virtual) { + setSelectedEventId(event.id); + setPanelMode('view'); + } }; const calendarEvents = filteredEvents.map((event) => ({ @@ -264,10 +246,6 @@ export default function CalendarPage() { }, })); - const isRecurring = (event: CalendarEvent): boolean => { - return !!(event.is_recurring || event.parent_event_id); - }; - const handleEventClick = (info: EventClickArg) => { const event = events.find((e) => String(e.id) === info.event.id); if (!event) return; @@ -276,18 +254,7 @@ export default function CalendarPage() { return; } setSelectedEventId(event.id); - }; - - const handleScopeChoice = (scope: 'this' | 'this_and_future') => { - if (!scopeEvent) return; - if (scopeAction === 'edit') { - setEditingEvent(scopeEvent); - setActiveEditScope(scope); - setShowForm(true); - setScopeDialogOpen(false); - } else if (scopeAction === 'delete') { - scopeDeleteMutation.mutate({ id: scopeEvent.id as number, scope }); - } + setPanelMode('view'); }; const handleEventDrop = (info: EventDropArg) => { @@ -295,7 +262,6 @@ export default function CalendarPage() { info.revert(); return; } - // Prevent drag-drop on recurring events — user must use scope dialog via click if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) { info.revert(); toast.info('Click the event to edit recurring events'); @@ -320,7 +286,6 @@ export default function CalendarPage() { info.revert(); return; } - // Prevent resize on recurring events — user must use scope dialog via click if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) { info.revert(); toast.info('Click the event to edit recurring events'); @@ -341,37 +306,43 @@ export default function CalendarPage() { }; const handleDateSelect = (selectInfo: DateSelectArg) => { - setSelectedStart(selectInfo.startStr); - setSelectedEnd(selectInfo.endStr); - setSelectedAllDay(selectInfo.allDay); - setShowForm(true); + setSelectedEventId(null); + setPanelMode('create'); + setCreateDefaults({ + start: selectInfo.startStr, + end: selectInfo.endStr, + allDay: selectInfo.allDay, + }); }; - const handleCloseForm = () => { + const handleCreateNew = () => { + setSelectedEventId(null); + setPanelMode('create'); + setCreateDefaults(null); + }; + + const handlePanelClose = () => { calendarRef.current?.getApi().unselect(); - setShowForm(false); - setEditingEvent(null); - setTemplateEvent(null); - setTemplateName(null); - setActiveEditScope(null); - setSelectedStart(null); - setSelectedEnd(null); - setSelectedAllDay(false); + setPanelMode('closed'); + setSelectedEventId(null); + setCreateDefaults(null); }; const handleUseTemplate = (template: EventTemplate) => { - setTemplateEvent({ - title: template.title, - description: template.description || '', - all_day: template.all_day, - calendar_id: template.calendar_id ?? undefined, - location_id: template.location_id || undefined, - is_starred: template.is_starred, - recurrence_rule: template.recurrence_rule || undefined, - } as Partial); - setTemplateName(template.name); - setEditingEvent(null); - setShowForm(true); + setSelectedEventId(null); + setPanelMode('create'); + setCreateDefaults({ + templateData: { + title: template.title, + description: template.description || '', + all_day: template.all_day, + calendar_id: template.calendar_id ?? undefined, + location_id: template.location_id || undefined, + is_starred: template.is_starred, + recurrence_rule: template.recurrence_rule || undefined, + } as Partial, + templateName: template.name, + }); }; const handleDatesSet = (arg: DatesSetArg) => { @@ -379,46 +350,6 @@ export default function CalendarPage() { setCurrentView(arg.view.type as CalendarView); }; - // Panel actions - const handlePanelEdit = () => { - if (!selectedEvent) return; - if (isRecurring(selectedEvent)) { - setScopeEvent(selectedEvent); - setScopeAction('edit'); - setScopeDialogOpen(true); - } else { - setEditingEvent(selectedEvent); - setShowForm(true); - } - }; - - const handlePanelDelete = () => { - if (!selectedEvent) return; - if (isRecurring(selectedEvent)) { - setScopeEvent(selectedEvent); - setScopeAction('delete'); - setScopeDialogOpen(true); - } else { - panelDeleteMutation.mutate(selectedEvent.id as number); - } - }; - - const panelDeleteMutation = useMutation({ - mutationFn: async (id: number) => { - await api.delete(`/events/${id}`); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); - queryClient.invalidateQueries({ queryKey: ['dashboard'] }); - queryClient.invalidateQueries({ queryKey: ['upcoming'] }); - toast.success('Event deleted'); - setSelectedEventId(null); - }, - onError: (error) => { - toast.error(getErrorMessage(error, 'Failed to delete event')); - }, - }); - const navigatePrev = () => calendarRef.current?.getApi().prev(); const navigateNext = () => calendarRef.current?.getApi().next(); const navigateToday = () => calendarRef.current?.getApi().today(); @@ -429,7 +360,7 @@ export default function CalendarPage() {
- {/* Custom toolbar — h-16 matches sidebar header */} + {/* Custom toolbar */}
+ +
+ {(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => ( + + ))} +
+

{calendarTitle}

@@ -483,30 +435,10 @@ export default function CalendarPage() { )}
- - -
- {(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => ( - - ))} -
{/* Calendar grid + event detail panel */} @@ -549,11 +481,11 @@ export default function CalendarPage() { }`} > setSelectedEventId(null)} + event={panelMode === 'view' ? selectedEvent : null} + isCreating={panelMode === 'create'} + createDefaults={createDefaults} + onClose={handlePanelClose} + onSaved={handlePanelClose} locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined} />
@@ -561,79 +493,26 @@ export default function CalendarPage() {
{/* Mobile detail panel overlay */} - {panelOpen && selectedEvent && ( + {panelOpen && (
setSelectedEventId(null)} + onClick={handlePanelClose} >
e.stopPropagation()} > setSelectedEventId(null)} + event={panelMode === 'view' ? selectedEvent : null} + isCreating={panelMode === 'create'} + createDefaults={createDefaults} + onClose={handlePanelClose} + onSaved={handlePanelClose} locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined} />
)} - - {showForm && ( - - )} - - {/* Recurring event scope dialog */} - - - - - {scopeAction === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'} - - -

- This is a recurring event. How would you like to proceed? -

-
- - - -
-
-
); } diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index bfa420d..0b71fd9 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -1,18 +1,69 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { format, parseISO } from 'date-fns'; -import { X, Pencil, Trash2, Clock, MapPin, AlignLeft, Repeat } from 'lucide-react'; -import type { CalendarEvent } from '@/types'; -import { Button } from '@/components/ui/button'; +import { + X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, +} from 'lucide-react'; +import api, { getErrorMessage } from '@/lib/api'; +import type { CalendarEvent, Location as LocationType, RecurrenceRule } from '@/types'; +import { useCalendars } from '@/hooks/useCalendars'; import { useConfirmAction } from '@/hooks/useConfirmAction'; import { formatUpdatedAt } from '@/components/shared/utils'; import CopyableField from '@/components/shared/CopyableField'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Select } from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import LocationPicker from '@/components/ui/location-picker'; -interface EventDetailPanelProps { - event: CalendarEvent | null; - onEdit: () => void; - onDelete: () => void; - deleteLoading?: boolean; - onClose: () => void; - locationName?: string; +// --- Helpers --- + +function toDateOnly(dt: string): string { + if (!dt) return ''; + return dt.split('T')[0]; +} + +function toDatetimeLocal(dt: string, fallbackTime = '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 = '09:00'): string { + if (!dt) return ''; + return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime); +} + +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())}`; +} + +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())}`; +} + +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())}`; } function formatRecurrenceRule(rule: string): string { @@ -44,162 +95,805 @@ function ordinal(n: number): string { return s[(v - 20) % 10] || s[v] || s[0]; } +function parseRecurrenceRule(raw?: string): RecurrenceRule | null { + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +// Python weekday: 0=Monday, 6=Sunday +const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + +// --- Types --- + +export interface CreateDefaults { + start?: string; + end?: string; + allDay?: boolean; + templateData?: Partial; + templateName?: string; +} + +interface EventDetailPanelProps { + event: CalendarEvent | null; + isCreating?: boolean; + createDefaults?: CreateDefaults | null; + onClose: () => void; + onSaved?: () => void; + onDeleted?: () => void; + locationName?: string; +} + +interface EditState { + title: string; + description: string; + start_datetime: string; + end_datetime: string; + all_day: boolean; + location_id: string; + calendar_id: string; + is_starred: boolean; + recurrence_type: string; + recurrence_interval: number; + recurrence_weekday: number; + recurrence_week: number; + recurrence_day: number; +} + +function buildEditStateFromEvent(event: CalendarEvent): EditState { + const rule = parseRecurrenceRule(event.recurrence_rule); + const isAllDay = event.all_day; + const displayEnd = isAllDay ? adjustAllDayEndForDisplay(event.end_datetime) : event.end_datetime; + return { + title: event.title, + description: event.description || '', + start_datetime: formatForInput(event.start_datetime, isAllDay, '09:00'), + end_datetime: formatForInput(displayEnd, isAllDay, '10:00'), + all_day: isAllDay, + location_id: event.location_id?.toString() || '', + calendar_id: event.calendar_id?.toString() || '', + is_starred: event.is_starred || false, + recurrence_type: rule?.type || '', + recurrence_interval: rule?.interval || 2, + recurrence_weekday: rule?.weekday ?? 1, + recurrence_week: rule?.week || 1, + recurrence_day: rule?.day || 1, + }; +} + +function buildCreateState(defaults: CreateDefaults | null, defaultCalendarId: string): EditState { + const source = defaults?.templateData; + const isAllDay = source?.all_day ?? defaults?.allDay ?? false; + const defaultStart = nowLocal(); + const defaultEnd = plusOneHour(defaultStart); + const rawStart = defaults?.start || defaultStart; + const rawEnd = defaults?.end || defaultEnd; + const displayEnd = isAllDay ? adjustAllDayEndForDisplay(rawEnd) : rawEnd; + const rule = parseRecurrenceRule(source?.recurrence_rule); + + return { + 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: source?.calendar_id?.toString() || defaultCalendarId, + is_starred: source?.is_starred || false, + recurrence_type: rule?.type || '', + recurrence_interval: rule?.interval || 2, + recurrence_weekday: rule?.weekday ?? 1, + recurrence_week: rule?.week || 1, + recurrence_day: rule?.day || 1, + }; +} + +function buildRecurrencePayload(state: EditState): RecurrenceRule | null { + if (!state.recurrence_type) return null; + switch (state.recurrence_type) { + case 'every_n_days': + return { type: 'every_n_days', interval: state.recurrence_interval }; + case 'weekly': + return { type: 'weekly' }; + case 'monthly_nth_weekday': + return { type: 'monthly_nth_weekday', week: state.recurrence_week, weekday: state.recurrence_weekday }; + case 'monthly_date': + return { type: 'monthly_date', day: state.recurrence_day }; + default: + return null; + } +} + +// --- Component --- + export default function EventDetailPanel({ event, - onEdit, - onDelete, - deleteLoading = false, + isCreating = false, + createDefaults, onClose, + onSaved, + onDeleted, locationName, }: EventDetailPanelProps) { - const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete); + const queryClient = useQueryClient(); + const { data: calendars = [] } = useCalendars(); + const selectableCalendars = calendars.filter((c) => !c.is_system); + const defaultCalendar = calendars.find((c) => c.is_default); - if (!event) return null; + const { data: locations = [] } = useQuery({ + queryKey: ['locations'], + queryFn: async () => { + const { data } = await api.get('/locations'); + return data; + }, + staleTime: 5 * 60 * 1000, + }); - const startDate = parseISO(event.start_datetime); - const endDate = event.end_datetime ? parseISO(event.end_datetime) : null; - const isRecurring = !!(event.is_recurring || event.parent_event_id); + const [isEditing, setIsEditing] = useState(false); + const [editState, setEditState] = useState(() => + isCreating + ? buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || '') + : event + ? buildEditStateFromEvent(event) + : buildCreateState(null, defaultCalendar?.id?.toString() || '') + ); + const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null); + const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null); + const [locationSearch, setLocationSearch] = useState(''); - const startStr = event.all_day - ? format(startDate, 'EEEE, MMMM d, yyyy') - : format(startDate, 'EEEE, MMMM d, yyyy · h:mm a'); + const isRecurring = !!(event?.is_recurring || event?.parent_event_id); + // Reset state when event changes + useEffect(() => { + setIsEditing(false); + setScopeStep(null); + setEditScope(null); + setLocationSearch(''); + if (event) setEditState(buildEditStateFromEvent(event)); + }, [event?.id]); + + // Enter edit mode when creating + useEffect(() => { + if (isCreating) { + setIsEditing(true); + setEditState(buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || '')); + setLocationSearch(''); + } + }, [isCreating, createDefaults]); + + // Initialize location search text from existing location + useEffect(() => { + if (isEditing && !isCreating && event?.location_id) { + const loc = locations.find((l) => l.id === event.location_id); + if (loc) setLocationSearch(loc.name); + } + }, [isEditing, isCreating, event?.location_id, locations]); + + const invalidateAll = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + queryClient.invalidateQueries({ queryKey: ['upcoming'] }); + }, [queryClient]); + + // --- Mutations --- + + const saveMutation = useMutation({ + mutationFn: async (data: EditState) => { + const rule = buildRecurrencePayload(data); + let endDt = data.end_datetime; + if (data.all_day && endDt) endDt = adjustAllDayEndForSave(endDt); + + const payload: Record = { + title: data.title, + description: data.description || null, + 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 (event && !isCreating) { + if (editScope) payload.edit_scope = editScope; + return api.put(`/events/${event.id}`, payload); + } else { + return api.post('/events', payload); + } + }, + onSuccess: () => { + invalidateAll(); + toast.success(isCreating ? 'Event created' : 'Event updated'); + if (isCreating) { + onClose(); + } else { + setIsEditing(false); + setEditScope(null); + } + onSaved?.(); + }, + onError: (error) => { + toast.error(getErrorMessage(error, isCreating ? 'Failed to create event' : 'Failed to update event')); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async () => { + const scope = editScope ? `?scope=${editScope}` : ''; + await api.delete(`/events/${event!.id}${scope}`); + }, + onSuccess: () => { + invalidateAll(); + toast.success('Event deleted'); + onClose(); + onDeleted?.(); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to delete event')); + }, + }); + + const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]); + const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete); + + // --- Handlers --- + + const handleEditStart = () => { + if (isRecurring) { + setScopeStep('edit'); + } else { + if (event) setEditState(buildEditStateFromEvent(event)); + setIsEditing(true); + } + }; + + const handleScopeSelect = (scope: 'this' | 'this_and_future') => { + setEditScope(scope); + if (scopeStep === 'edit') { + if (event) setEditState(buildEditStateFromEvent(event)); + setIsEditing(true); + setScopeStep(null); + } else if (scopeStep === 'delete') { + // Delete with scope — execute immediately + setScopeStep(null); + // The deleteMutation will read editScope, but we need to set it first + // Since setState is async, use the mutation directly with the scope + const scopeParam = `?scope=${scope}`; + api.delete(`/events/${event!.id}${scopeParam}`).then(() => { + invalidateAll(); + toast.success('Event(s) deleted'); + onClose(); + onDeleted?.(); + }).catch((error) => { + toast.error(getErrorMessage(error, 'Failed to delete event')); + }); + } + }; + + const handleEditCancel = () => { + setIsEditing(false); + setEditScope(null); + setLocationSearch(''); + if (isCreating) { + onClose(); + } else if (event) { + setEditState(buildEditStateFromEvent(event)); + } + }; + + const handleEditSave = () => { + saveMutation.mutate(editState); + }; + + const handleDeleteStart = () => { + if (isRecurring) { + setScopeStep('delete'); + } else { + handleDeleteClick(); + } + }; + + // --- Render helpers --- + + const updateField = (key: K, value: EditState[K]) => { + setEditState((s) => ({ ...s, [key]: value })); + }; + + // Empty state + if (!event && !isCreating) { + return ( +
+ +

Select an event to view details

+
+ ); + } + + // View mode data + const startDate = event ? parseISO(event.start_datetime) : null; + const endDate = event?.end_datetime ? parseISO(event.end_datetime) : null; + const startStr = startDate + ? event!.all_day + ? format(startDate, 'EEEE, MMMM d, yyyy') + : format(startDate, 'EEEE, MMMM d, yyyy · h:mm a') + : ''; const endStr = endDate - ? event.all_day + ? event!.all_day ? format(endDate, 'EEEE, MMMM d, yyyy') : format(endDate, 'h:mm a') : null; + const panelTitle = isCreating + ? createDefaults?.templateName + ? `New Event from ${createDefaults.templateName}` + : 'New Event' + : event?.title || ''; + return (
{/* Header */} -
-
-
-
-

{event.title}

- {event.calendar_name} +
+
+ {isEditing ? ( +
+ {isCreating ? ( +

{panelTitle}

+ ) : ( + updateField('title', e.target.value)} + className="h-8 text-base font-semibold" + placeholder="Event title" + autoFocus + /> + )} +
+ ) : scopeStep ? ( +

+ {scopeStep === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'} +

+ ) : ( +
+
+
+

{event?.title}

+ {event?.calendar_name} +
+
+ )} + +
+ {scopeStep ? ( + + ) : isEditing ? ( + <> + + + + ) : ( + <> + {!event?.is_virtual && ( + <> + + {confirmingDelete ? ( + + ) : ( + + )} + + )} + + + )}
-
{/* Body */}
- {/* Calendar */} -
-

Calendar

-
-
- {event.calendar_name} -
-
- - {/* Start */} -
-

Start

- -
- - {/* End */} - {endStr && ( -
-

End

- -
- )} - - {/* Location */} - {locationName && ( -
-

Location

- -
- )} - - {/* Description */} - {event.description && ( -
-

Description

-
- -

{event.description}

+ {scopeStep ? ( + /* Scope selection step */ +
+

+ This is a recurring event. How would you like to proceed? +

+
+ + +
- )} + ) : isEditing ? ( + /* Edit / Create mode */ +
+ {/* Title (only shown in body for create mode; edit mode has it in header) */} + {isCreating && ( +
+ + updateField('title', e.target.value)} + placeholder="Event title" + required + autoFocus + /> +
+ )} + +
+ +