diff --git a/frontend/src/components/calendar/CalendarForm.tsx b/frontend/src/components/calendar/CalendarForm.tsx new file mode 100644 index 0000000..dfa2b84 --- /dev/null +++ b/frontend/src/components/calendar/CalendarForm.tsx @@ -0,0 +1,144 @@ +import { useState, FormEvent } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import api, { getErrorMessage } from '@/lib/api'; +import type { Calendar } from '@/types'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; + +interface CalendarFormProps { + calendar: Calendar | null; + onClose: () => void; +} + +const colorSwatches = [ + '#3b82f6', // blue + '#ef4444', // red + '#f97316', // orange + '#eab308', // yellow + '#22c55e', // green + '#8b5cf6', // purple + '#ec4899', // pink + '#06b6d4', // cyan +]; + +export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { + const queryClient = useQueryClient(); + const [name, setName] = useState(calendar?.name || ''); + const [color, setColor] = useState(calendar?.color || '#3b82f6'); + + const mutation = useMutation({ + mutationFn: async () => { + if (calendar) { + const { data } = await api.put(`/calendars/${calendar.id}`, { name, color }); + return data; + } else { + const { data } = await api.post('/calendars', { name, color }); + return data; + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendars'] }); + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + toast.success(calendar ? 'Calendar updated' : 'Calendar created'); + onClose(); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to save calendar')); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async () => { + await api.delete(`/calendars/${calendar?.id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendars'] }); + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + toast.success('Calendar deleted'); + onClose(); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to delete calendar')); + }, + }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + mutation.mutate(); + }; + + const canDelete = calendar && !calendar.is_default && !calendar.is_system; + + return ( + + + + + {calendar ? 'Edit Calendar' : 'New Calendar'} + +
+
+ + setName(e.target.value)} + placeholder="Calendar name" + required + /> +
+ +
+ +
+ {colorSwatches.map((c) => ( +
+
+ + + {canDelete && ( + + )} + + + +
+
+
+ ); +} diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index a138e06..b2f26ca 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -1,15 +1,27 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, 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 } from '@fullcalendar/core'; +import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; import type { CalendarEvent } from '@/types'; +import { useCalendars } from '@/hooks/useCalendars'; +import { Button } from '@/components/ui/button'; +import CalendarSidebar from './CalendarSidebar'; import EventForm from './EventForm'; +type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay'; + +const viewLabels: Record = { + dayGridMonth: 'Month', + timeGridWeek: 'Week', + timeGridDay: 'Day', +}; + export default function CalendarPage() { const queryClient = useQueryClient(); const calendarRef = useRef(null); @@ -18,6 +30,10 @@ export default function CalendarPage() { 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 { data: calendars = [] } = useCalendars(); const { data: events = [] } = useQuery({ queryKey: ['calendar-events'], @@ -27,6 +43,11 @@ export default function CalendarPage() { }, }); + 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())}`; @@ -39,7 +60,6 @@ export default function CalendarPage() { allDay: boolean, revert: () => void, ) => { - // Optimistically update cache so re-renders don't snap back queryClient.setQueryData(['calendar-events'], (old) => old?.map((e) => e.id === id @@ -47,7 +67,6 @@ export default function CalendarPage() { : e, ), ); - eventMutation.mutate({ id, start, end, allDay, revert }); }; @@ -82,25 +101,40 @@ export default function CalendarPage() { }, }); - const calendarEvents = events.map((event) => ({ - id: event.id.toString(), + 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.color || 'hsl(var(--accent-color))', - borderColor: event.color || 'hsl(var(--accent-color))', + backgroundColor: event.calendar_color || 'hsl(var(--accent-color))', + borderColor: event.calendar_color || 'hsl(var(--accent-color))', + extendedProps: { + is_virtual: event.is_virtual, + }, })); const handleEventClick = (info: EventClickArg) => { - const event = events.find((e) => e.id.toString() === info.event.id); - if (event) { - setEditingEvent(event); - setShowForm(true); + 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; } + setEditingEvent(event); + setShowForm(true); }; const handleEventDrop = (info: EventDropArg) => { + if (info.event.extendedProps.is_virtual) { + info.revert(); + return; + } const id = parseInt(info.event.id); const start = info.event.allDay ? info.event.startStr @@ -116,6 +150,10 @@ export default function CalendarPage() { }; const handleEventResize = (info: { event: EventDropArg['event']; revert: () => void }) => { + if (info.event.extendedProps.is_virtual) { + info.revert(); + return; + } const id = parseInt(info.event.id); const start = info.event.allDay ? info.event.startStr @@ -146,36 +184,80 @@ export default function CalendarPage() { setSelectedAllDay(false); }; - return ( -
-
-

Calendar

-
+ 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 ( +
+ + +
+ {/* Custom toolbar */} +
+
+ + +
+ +

{calendarTitle}

+
+ {(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => ( + + ))} +
+
+ + {/* Calendar grid */} +
+
+ +
diff --git a/frontend/src/components/calendar/CalendarSidebar.tsx b/frontend/src/components/calendar/CalendarSidebar.tsx new file mode 100644 index 0000000..0dce1e1 --- /dev/null +++ b/frontend/src/components/calendar/CalendarSidebar.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Pencil } from 'lucide-react'; +import { toast } from 'sonner'; +import api, { getErrorMessage } from '@/lib/api'; +import type { Calendar } from '@/types'; +import { useCalendars } from '@/hooks/useCalendars'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import CalendarForm from './CalendarForm'; + +export default function CalendarSidebar() { + const queryClient = useQueryClient(); + const { data: calendars = [] } = useCalendars(); + const [showForm, setShowForm] = useState(false); + const [editingCalendar, setEditingCalendar] = useState(null); + + const toggleMutation = useMutation({ + mutationFn: async ({ id, is_visible }: { id: number; is_visible: boolean }) => { + await api.put(`/calendars/${id}`, { is_visible }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendars'] }); + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to update calendar')); + }, + }); + + const handleToggle = (calendar: Calendar) => { + toggleMutation.mutate({ id: calendar.id, is_visible: !calendar.is_visible }); + }; + + const handleEdit = (calendar: Calendar) => { + setEditingCalendar(calendar); + setShowForm(true); + }; + + const handleCloseForm = () => { + setShowForm(false); + setEditingCalendar(null); + }; + + return ( +
+
+ Calendars + +
+
+ {calendars.map((cal) => ( +
+ handleToggle(cal)} + className="shrink-0" + style={{ + accentColor: cal.color, + borderColor: cal.is_visible ? cal.color : undefined, + backgroundColor: cal.is_visible ? cal.color : undefined, + }} + /> + + {cal.name} + {!cal.is_system && ( + + )} +
+ ))} +
+ + {showForm && ( + + )} +
+ ); +} diff --git a/frontend/src/components/calendar/EventForm.tsx b/frontend/src/components/calendar/EventForm.tsx index 6e3dd13..0d16b43 100644 --- a/frontend/src/components/calendar/EventForm.tsx +++ b/frontend/src/components/calendar/EventForm.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import api, { getErrorMessage } from '@/lib/api'; import type { CalendarEvent, Location } from '@/types'; +import { useCalendars } from '@/hooks/useCalendars'; import { Dialog, DialogContent, @@ -26,31 +27,17 @@ interface EventFormProps { onClose: () => void; } -const colorPresets = [ - { name: 'Accent', value: '' }, - { name: 'Red', value: '#ef4444' }, - { name: 'Orange', value: '#f97316' }, - { name: 'Yellow', value: '#eab308' }, - { name: 'Green', value: '#22c55e' }, - { name: 'Blue', value: '#3b82f6' }, - { name: 'Purple', value: '#8b5cf6' }, - { name: 'Pink', value: '#ec4899' }, -]; - -// Extract just the date portion (YYYY-MM-DD) from any date/datetime string function toDateOnly(dt: string): string { if (!dt) return ''; return dt.split('T')[0]; } -// Ensure a datetime string is in datetime-local format (YYYY-MM-DDThh:mm) function toDatetimeLocal(dt: string, fallbackTime: string = '09:00'): string { if (!dt) return ''; - if (dt.includes('T')) return dt.slice(0, 16); // trim seconds/timezone + if (dt.includes('T')) return dt.slice(0, 16); return `${dt}T${fallbackTime}`; } -// Format a date/datetime string for the correct input type function formatForInput(dt: string, allDay: boolean, fallbackTime: string = '09:00'): string { if (!dt) return ''; return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime); @@ -58,9 +45,14 @@ function formatForInput(dt: string, allDay: boolean, fallbackTime: string = '09: export default function EventForm({ event, initialStart, initialEnd, initialAllDay, onClose }: EventFormProps) { const queryClient = useQueryClient(); + const { data: calendars = [] } = useCalendars(); const isAllDay = event?.all_day ?? initialAllDay ?? false; const rawStart = event?.start_datetime || initialStart || ''; const rawEnd = event?.end_datetime || initialEnd || ''; + + const defaultCalendar = calendars.find((c) => c.is_default); + const initialCalendarId = event?.calendar_id?.toString() || defaultCalendar?.id?.toString() || ''; + const [formData, setFormData] = useState({ title: event?.title || '', description: event?.description || '', @@ -68,7 +60,7 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD end_datetime: formatForInput(rawEnd, isAllDay, '10:00'), all_day: isAllDay, location_id: event?.location_id?.toString() || '', - color: event?.color || '', + calendar_id: initialCalendarId, recurrence_rule: event?.recurrence_rule || '', is_starred: event?.is_starred || false, }); @@ -81,11 +73,15 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD }, }); + // Filter out system calendars (Birthdays) from the dropdown + const selectableCalendars = calendars.filter((c) => !c.is_system); + const mutation = useMutation({ mutationFn: async (data: typeof formData) => { const payload = { ...data, location_id: data.location_id ? parseInt(data.location_id) : null, + calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null, }; if (event) { const response = await api.put(`/events/${event.id}`, payload); @@ -196,6 +192,21 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
+
+ + +
+
setFormData({ ...formData, color: e.target.value })} - > - {colorPresets.map((preset) => ( - - ))} - -
-