- EventTemplate model: add server_default=func.now() to created_at to match migration 011 and prevent autogenerate drift - CalendarPage: use nullish coalescing for template calendar_id instead of || 0 which produced an invalid falsy ID Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
444 lines
15 KiB
TypeScript
444 lines
15 KiB
TypeScript
import { useState, useRef, useEffect, useMemo } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import FullCalendar from '@fullcalendar/react';
|
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
|
import timeGridPlugin from '@fullcalendar/timegrid';
|
|
import interactionPlugin from '@fullcalendar/interaction';
|
|
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
|
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
import api, { getErrorMessage } from '@/lib/api';
|
|
import type { CalendarEvent, EventTemplate } from '@/types';
|
|
import { useCalendars } from '@/hooks/useCalendars';
|
|
import { useSettings } from '@/hooks/useSettings';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import CalendarSidebar from './CalendarSidebar';
|
|
import EventForm from './EventForm';
|
|
|
|
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
|
|
|
|
const viewLabels: Record<CalendarView, string> = {
|
|
dayGridMonth: 'Month',
|
|
timeGridWeek: 'Week',
|
|
timeGridDay: 'Day',
|
|
};
|
|
|
|
type ScopeAction = 'edit' | 'delete';
|
|
|
|
export default function CalendarPage() {
|
|
const queryClient = useQueryClient();
|
|
const calendarRef = useRef<FullCalendar>(null);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null);
|
|
const [selectedStart, setSelectedStart] = useState<string | null>(null);
|
|
const [selectedEnd, setSelectedEnd] = useState<string | null>(null);
|
|
const [selectedAllDay, setSelectedAllDay] = useState(false);
|
|
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
|
|
const [calendarTitle, setCalendarTitle] = useState('');
|
|
|
|
const [templateEvent, setTemplateEvent] = useState<Partial<CalendarEvent> | null>(null);
|
|
const [templateName, setTemplateName] = useState<string | null>(null);
|
|
|
|
// Scope dialog state
|
|
const [scopeDialogOpen, setScopeDialogOpen] = useState(false);
|
|
const [scopeAction, setScopeAction] = useState<ScopeAction>('edit');
|
|
const [scopeEvent, setScopeEvent] = useState<CalendarEvent | null>(null);
|
|
const [activeEditScope, setActiveEditScope] = useState<'this' | 'this_and_future' | null>(null);
|
|
|
|
const { settings } = useSettings();
|
|
const { data: calendars = [] } = useCalendars();
|
|
const calendarContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Resize FullCalendar when container size changes (e.g. sidebar collapse)
|
|
useEffect(() => {
|
|
const el = calendarContainerRef.current;
|
|
if (!el) return;
|
|
const observer = new ResizeObserver(() => {
|
|
calendarRef.current?.getApi().updateSize();
|
|
});
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
// Scroll wheel navigation in month view
|
|
useEffect(() => {
|
|
const el = calendarContainerRef.current;
|
|
if (!el) return;
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const handleWheel = (e: WheelEvent) => {
|
|
const api = calendarRef.current?.getApi();
|
|
if (!api || api.view.type !== 'dayGridMonth') return;
|
|
e.preventDefault();
|
|
if (debounceTimer) return;
|
|
debounceTimer = setTimeout(() => {
|
|
debounceTimer = null;
|
|
}, 300);
|
|
if (e.deltaY > 0) api.next();
|
|
else if (e.deltaY < 0) api.prev();
|
|
};
|
|
el.addEventListener('wheel', handleWheel, { passive: false });
|
|
return () => el.removeEventListener('wheel', handleWheel);
|
|
}, []);
|
|
|
|
const { data: events = [] } = useQuery({
|
|
queryKey: ['calendar-events'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<CalendarEvent[]>('/events');
|
|
return data;
|
|
},
|
|
});
|
|
|
|
const visibleCalendarIds = useMemo(
|
|
() => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)),
|
|
[calendars],
|
|
);
|
|
|
|
const toLocalDatetime = (d: Date): string => {
|
|
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())}:${pad(d.getSeconds())}`;
|
|
};
|
|
|
|
const updateEventTimes = (
|
|
id: number,
|
|
start: string,
|
|
end: string,
|
|
allDay: boolean,
|
|
revert: () => void,
|
|
) => {
|
|
queryClient.setQueryData<CalendarEvent[]>(['calendar-events'], (old) =>
|
|
old?.map((e) =>
|
|
e.id === id
|
|
? { ...e, start_datetime: start, end_datetime: end, all_day: allDay }
|
|
: e,
|
|
),
|
|
);
|
|
eventMutation.mutate({ id, start, end, allDay, revert });
|
|
};
|
|
|
|
const eventMutation = useMutation({
|
|
mutationFn: async ({
|
|
id,
|
|
start,
|
|
end,
|
|
allDay,
|
|
}: {
|
|
id: number;
|
|
start: string;
|
|
end: string;
|
|
allDay: boolean;
|
|
revert: () => void;
|
|
}) => {
|
|
const response = await api.put(`/events/${id}`, {
|
|
start_datetime: start,
|
|
end_datetime: end,
|
|
all_day: allDay,
|
|
});
|
|
return response.data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
|
toast.success('Event updated');
|
|
},
|
|
onError: (error, variables) => {
|
|
variables.revert();
|
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
|
toast.error(getErrorMessage(error, 'Failed to update event'));
|
|
},
|
|
});
|
|
|
|
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);
|
|
},
|
|
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));
|
|
}, [events, visibleCalendarIds, calendars.length]);
|
|
|
|
const calendarEvents = filteredEvents.map((event) => ({
|
|
id: String(event.id),
|
|
title: event.title,
|
|
start: event.start_datetime,
|
|
end: event.end_datetime || undefined,
|
|
allDay: event.all_day,
|
|
backgroundColor: event.calendar_color || 'hsl(var(--accent-color))',
|
|
borderColor: event.calendar_color || 'hsl(var(--accent-color))',
|
|
extendedProps: {
|
|
is_virtual: event.is_virtual,
|
|
is_recurring: event.is_recurring,
|
|
parent_event_id: event.parent_event_id,
|
|
},
|
|
}));
|
|
|
|
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;
|
|
if (event.is_virtual) {
|
|
toast.info(`${event.title} — from People contacts`);
|
|
return;
|
|
}
|
|
|
|
if (isRecurring(event)) {
|
|
setScopeEvent(event);
|
|
setScopeAction('edit');
|
|
setScopeDialogOpen(true);
|
|
} else {
|
|
setEditingEvent(event);
|
|
setShowForm(true);
|
|
}
|
|
};
|
|
|
|
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 });
|
|
}
|
|
};
|
|
|
|
const handleEventDrop = (info: EventDropArg) => {
|
|
if (info.event.extendedProps.is_virtual) {
|
|
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');
|
|
return;
|
|
}
|
|
const id = parseInt(info.event.id);
|
|
const start = info.event.allDay
|
|
? info.event.startStr
|
|
: info.event.start
|
|
? toLocalDatetime(info.event.start)
|
|
: info.event.startStr;
|
|
const end = info.event.allDay
|
|
? info.event.endStr || info.event.startStr
|
|
: info.event.end
|
|
? toLocalDatetime(info.event.end)
|
|
: start;
|
|
updateEventTimes(id, start, end, info.event.allDay, info.revert);
|
|
};
|
|
|
|
const handleEventResize = (info: { event: EventDropArg['event']; revert: () => void }) => {
|
|
if (info.event.extendedProps.is_virtual) {
|
|
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');
|
|
return;
|
|
}
|
|
const id = parseInt(info.event.id);
|
|
const start = info.event.allDay
|
|
? info.event.startStr
|
|
: info.event.start
|
|
? toLocalDatetime(info.event.start)
|
|
: info.event.startStr;
|
|
const end = info.event.allDay
|
|
? info.event.endStr || info.event.startStr
|
|
: info.event.end
|
|
? toLocalDatetime(info.event.end)
|
|
: start;
|
|
updateEventTimes(id, start, end, info.event.allDay, info.revert);
|
|
};
|
|
|
|
const handleDateSelect = (selectInfo: DateSelectArg) => {
|
|
setSelectedStart(selectInfo.startStr);
|
|
setSelectedEnd(selectInfo.endStr);
|
|
setSelectedAllDay(selectInfo.allDay);
|
|
setShowForm(true);
|
|
};
|
|
|
|
const handleCloseForm = () => {
|
|
calendarRef.current?.getApi().unselect();
|
|
setShowForm(false);
|
|
setEditingEvent(null);
|
|
setTemplateEvent(null);
|
|
setTemplateName(null);
|
|
setActiveEditScope(null);
|
|
setSelectedStart(null);
|
|
setSelectedEnd(null);
|
|
setSelectedAllDay(false);
|
|
};
|
|
|
|
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<CalendarEvent>);
|
|
setTemplateName(template.name);
|
|
setEditingEvent(null);
|
|
setShowForm(true);
|
|
};
|
|
|
|
const handleDatesSet = (arg: DatesSetArg) => {
|
|
setCalendarTitle(arg.view.title);
|
|
setCurrentView(arg.view.type as CalendarView);
|
|
};
|
|
|
|
const navigatePrev = () => calendarRef.current?.getApi().prev();
|
|
const navigateNext = () => calendarRef.current?.getApi().next();
|
|
const navigateToday = () => calendarRef.current?.getApi().today();
|
|
const changeView = (view: CalendarView) => calendarRef.current?.getApi().changeView(view);
|
|
|
|
return (
|
|
<div className="flex h-full overflow-hidden">
|
|
<CalendarSidebar onUseTemplate={handleUseTemplate} />
|
|
|
|
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
|
|
{/* Custom toolbar — h-16 matches sidebar header */}
|
|
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
|
<div className="flex items-center gap-1">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigateNext}>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<Button variant="outline" size="sm" className="h-8" onClick={navigateToday}>
|
|
Today
|
|
</Button>
|
|
<h2 className="text-lg font-semibold font-heading flex-1">{calendarTitle}</h2>
|
|
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
|
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
|
|
<button
|
|
key={view}
|
|
onClick={() => changeView(view)}
|
|
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
|
|
currentView === view
|
|
? 'bg-accent/15 text-accent'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
|
}`}
|
|
style={{
|
|
backgroundColor: currentView === view ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
|
color: currentView === view ? 'hsl(var(--accent-color))' : undefined,
|
|
}}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Calendar grid */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="h-full">
|
|
<FullCalendar
|
|
key={`fc-${settings?.first_day_of_week ?? 0}`}
|
|
ref={calendarRef}
|
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
|
initialView="dayGridMonth"
|
|
headerToolbar={false}
|
|
firstDay={settings?.first_day_of_week ?? 0}
|
|
events={calendarEvents}
|
|
editable={true}
|
|
selectable={true}
|
|
selectMirror={true}
|
|
unselectAuto={false}
|
|
dayMaxEvents={true}
|
|
weekends={true}
|
|
nowIndicator={true}
|
|
eventClick={handleEventClick}
|
|
eventDrop={handleEventDrop}
|
|
eventResize={handleEventResize}
|
|
select={handleDateSelect}
|
|
datesSet={handleDatesSet}
|
|
height="100%"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{showForm && (
|
|
<EventForm
|
|
event={editingEvent}
|
|
templateData={templateEvent}
|
|
templateName={templateName}
|
|
initialStart={selectedStart}
|
|
initialEnd={selectedEnd}
|
|
initialAllDay={selectedAllDay}
|
|
editScope={activeEditScope}
|
|
onClose={handleCloseForm}
|
|
/>
|
|
)}
|
|
|
|
{/* Recurring event scope dialog */}
|
|
<Dialog open={scopeDialogOpen} onOpenChange={setScopeDialogOpen}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{scopeAction === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm text-muted-foreground">
|
|
This is a recurring event. How would you like to proceed?
|
|
</p>
|
|
<div className="flex flex-col gap-2 mt-2">
|
|
<Button
|
|
variant="outline"
|
|
className="w-full justify-center"
|
|
onClick={() => handleScopeChoice('this')}
|
|
>
|
|
This event only
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="w-full justify-center"
|
|
onClick={() => handleScopeChoice('this_and_future')}
|
|
>
|
|
This and all future events
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full justify-center"
|
|
onClick={() => {
|
|
setScopeDialogOpen(false);
|
|
setScopeEvent(null);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|