UMBRA/frontend/src/components/calendar/CalendarPage.tsx
Kyle Pope 27003374e3 Fix QA review warnings: model server_default, calendar_id coercion
- 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>
2026-02-23 13:50:31 +08:00

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>
);
}