Add calendar redesign frontend with multi-calendar UI
- Custom toolbar replacing FullCalendar defaults (nav, today, view switcher) - Calendar sidebar with visibility toggles, color dots, add/edit support - CalendarForm dialog for creating/editing calendars with color swatches - EventForm updated to use calendar dropdown instead of color picker - CSS overrides: accent-tinted today highlight, now indicator, rounded event pills - Types updated for Calendar interface and mixed id types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
093bceed06
commit
5b056cf674
144
frontend/src/components/calendar/CalendarForm.tsx
Normal file
144
frontend/src/components/calendar/CalendarForm.tsx
Normal file
@ -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 (
|
||||||
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogClose onClick={onClose} />
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{calendar ? 'Edit Calendar' : 'New Calendar'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cal-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="cal-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Calendar name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Color</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{colorSwatches.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className="h-8 w-8 rounded-full border-2 transition-all duration-150 hover:scale-110"
|
||||||
|
style={{
|
||||||
|
backgroundColor: c,
|
||||||
|
borderColor: color === c ? 'hsl(0 0% 98%)' : 'transparent',
|
||||||
|
boxShadow: color === c ? `0 0 0 2px ${c}40` : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{canDelete && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteMutation.mutate()}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="mr-auto"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? 'Saving...' : calendar ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,15 +1,27 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useMemo } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import FullCalendar from '@fullcalendar/react';
|
import FullCalendar from '@fullcalendar/react';
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||||
import interactionPlugin from '@fullcalendar/interaction';
|
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 api, { getErrorMessage } from '@/lib/api';
|
||||||
import type { CalendarEvent } from '@/types';
|
import type { CalendarEvent } from '@/types';
|
||||||
|
import { useCalendars } from '@/hooks/useCalendars';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import CalendarSidebar from './CalendarSidebar';
|
||||||
import EventForm from './EventForm';
|
import EventForm from './EventForm';
|
||||||
|
|
||||||
|
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
|
||||||
|
|
||||||
|
const viewLabels: Record<CalendarView, string> = {
|
||||||
|
dayGridMonth: 'Month',
|
||||||
|
timeGridWeek: 'Week',
|
||||||
|
timeGridDay: 'Day',
|
||||||
|
};
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const calendarRef = useRef<FullCalendar>(null);
|
const calendarRef = useRef<FullCalendar>(null);
|
||||||
@ -18,6 +30,10 @@ export default function CalendarPage() {
|
|||||||
const [selectedStart, setSelectedStart] = useState<string | null>(null);
|
const [selectedStart, setSelectedStart] = useState<string | null>(null);
|
||||||
const [selectedEnd, setSelectedEnd] = useState<string | null>(null);
|
const [selectedEnd, setSelectedEnd] = useState<string | null>(null);
|
||||||
const [selectedAllDay, setSelectedAllDay] = useState(false);
|
const [selectedAllDay, setSelectedAllDay] = useState(false);
|
||||||
|
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
|
||||||
|
const [calendarTitle, setCalendarTitle] = useState('');
|
||||||
|
|
||||||
|
const { data: calendars = [] } = useCalendars();
|
||||||
|
|
||||||
const { data: events = [] } = useQuery({
|
const { data: events = [] } = useQuery({
|
||||||
queryKey: ['calendar-events'],
|
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 toLocalDatetime = (d: Date): string => {
|
||||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
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())}`;
|
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,
|
allDay: boolean,
|
||||||
revert: () => void,
|
revert: () => void,
|
||||||
) => {
|
) => {
|
||||||
// Optimistically update cache so re-renders don't snap back
|
|
||||||
queryClient.setQueryData<CalendarEvent[]>(['calendar-events'], (old) =>
|
queryClient.setQueryData<CalendarEvent[]>(['calendar-events'], (old) =>
|
||||||
old?.map((e) =>
|
old?.map((e) =>
|
||||||
e.id === id
|
e.id === id
|
||||||
@ -47,7 +67,6 @@ export default function CalendarPage() {
|
|||||||
: e,
|
: e,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
eventMutation.mutate({ id, start, end, allDay, revert });
|
eventMutation.mutate({ id, start, end, allDay, revert });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -82,25 +101,40 @@ export default function CalendarPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const calendarEvents = events.map((event) => ({
|
const filteredEvents = useMemo(() => {
|
||||||
id: event.id.toString(),
|
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,
|
title: event.title,
|
||||||
start: event.start_datetime,
|
start: event.start_datetime,
|
||||||
end: event.end_datetime || undefined,
|
end: event.end_datetime || undefined,
|
||||||
allDay: event.all_day,
|
allDay: event.all_day,
|
||||||
backgroundColor: event.color || 'hsl(var(--accent-color))',
|
backgroundColor: event.calendar_color || 'hsl(var(--accent-color))',
|
||||||
borderColor: event.color || 'hsl(var(--accent-color))',
|
borderColor: event.calendar_color || 'hsl(var(--accent-color))',
|
||||||
|
extendedProps: {
|
||||||
|
is_virtual: event.is_virtual,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleEventClick = (info: EventClickArg) => {
|
const handleEventClick = (info: EventClickArg) => {
|
||||||
const event = events.find((e) => e.id.toString() === info.event.id);
|
const event = events.find((e) => String(e.id) === info.event.id);
|
||||||
if (event) {
|
if (!event) return;
|
||||||
setEditingEvent(event);
|
if (event.is_virtual) {
|
||||||
setShowForm(true);
|
toast.info(`${event.title} — from People contacts`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setEditingEvent(event);
|
||||||
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEventDrop = (info: EventDropArg) => {
|
const handleEventDrop = (info: EventDropArg) => {
|
||||||
|
if (info.event.extendedProps.is_virtual) {
|
||||||
|
info.revert();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id = parseInt(info.event.id);
|
const id = parseInt(info.event.id);
|
||||||
const start = info.event.allDay
|
const start = info.event.allDay
|
||||||
? info.event.startStr
|
? info.event.startStr
|
||||||
@ -116,6 +150,10 @@ export default function CalendarPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEventResize = (info: { event: EventDropArg['event']; revert: () => void }) => {
|
const handleEventResize = (info: { event: EventDropArg['event']; revert: () => void }) => {
|
||||||
|
if (info.event.extendedProps.is_virtual) {
|
||||||
|
info.revert();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id = parseInt(info.event.id);
|
const id = parseInt(info.event.id);
|
||||||
const start = info.event.allDay
|
const start = info.event.allDay
|
||||||
? info.event.startStr
|
? info.event.startStr
|
||||||
@ -146,36 +184,80 @@ export default function CalendarPage() {
|
|||||||
setSelectedAllDay(false);
|
setSelectedAllDay(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleDatesSet = (arg: DatesSetArg) => {
|
||||||
<div className="flex flex-col h-full">
|
setCalendarTitle(arg.view.title);
|
||||||
<div className="border-b bg-card px-6 py-4">
|
setCurrentView(arg.view.type as CalendarView);
|
||||||
<h1 className="text-3xl font-bold">Calendar</h1>
|
};
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
const navigatePrev = () => calendarRef.current?.getApi().prev();
|
||||||
<div className="bg-card rounded-lg border p-4">
|
const navigateNext = () => calendarRef.current?.getApi().next();
|
||||||
<FullCalendar
|
const navigateToday = () => calendarRef.current?.getApi().today();
|
||||||
ref={calendarRef}
|
const changeView = (view: CalendarView) => calendarRef.current?.getApi().changeView(view);
|
||||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
|
||||||
initialView="dayGridMonth"
|
return (
|
||||||
headerToolbar={{
|
<div className="flex h-full overflow-hidden">
|
||||||
left: 'prev,next today',
|
<CalendarSidebar />
|
||||||
center: 'title',
|
|
||||||
right: 'dayGridMonth,timeGridWeek,timeGridDay',
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
}}
|
{/* Custom toolbar */}
|
||||||
events={calendarEvents}
|
<div className="border-b bg-card px-6 py-3 flex items-center gap-4">
|
||||||
editable={true}
|
<div className="flex items-center gap-1">
|
||||||
selectable={true}
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
|
||||||
selectMirror={true}
|
<ChevronLeft className="h-4 w-4" />
|
||||||
unselectAuto={false}
|
</Button>
|
||||||
dayMaxEvents={true}
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigateNext}>
|
||||||
weekends={true}
|
<ChevronRight className="h-4 w-4" />
|
||||||
eventClick={handleEventClick}
|
</Button>
|
||||||
eventDrop={handleEventDrop}
|
</div>
|
||||||
eventResize={handleEventResize}
|
<Button variant="outline" size="sm" className="h-8" onClick={navigateToday}>
|
||||||
select={handleDateSelect}
|
Today
|
||||||
height="auto"
|
</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 p-4">
|
||||||
|
<div className="bg-card rounded-lg border p-3 h-full">
|
||||||
|
<FullCalendar
|
||||||
|
ref={calendarRef}
|
||||||
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||||
|
initialView="dayGridMonth"
|
||||||
|
headerToolbar={false}
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
99
frontend/src/components/calendar/CalendarSidebar.tsx
Normal file
99
frontend/src/components/calendar/CalendarSidebar.tsx
Normal file
@ -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<Calendar | null>(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 (
|
||||||
|
<div className="w-56 shrink-0 border-r bg-card flex flex-col">
|
||||||
|
<div className="p-4 border-b flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold font-heading text-foreground">Calendars</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => { setEditingCalendar(null); setShowForm(true); }}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-3 space-y-0.5">
|
||||||
|
{calendars.map((cal) => (
|
||||||
|
<div
|
||||||
|
key={cal.id}
|
||||||
|
className="group flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={cal.is_visible}
|
||||||
|
onChange={() => handleToggle(cal)}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{
|
||||||
|
accentColor: cal.color,
|
||||||
|
borderColor: cal.is_visible ? cal.color : undefined,
|
||||||
|
backgroundColor: cal.is_visible ? cal.color : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: cal.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-foreground truncate flex-1">{cal.name}</span>
|
||||||
|
{!cal.is_system && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(cal)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<CalendarForm
|
||||||
|
calendar={editingCalendar}
|
||||||
|
onClose={handleCloseForm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import type { CalendarEvent, Location } from '@/types';
|
import type { CalendarEvent, Location } from '@/types';
|
||||||
|
import { useCalendars } from '@/hooks/useCalendars';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -26,31 +27,17 @@ interface EventFormProps {
|
|||||||
onClose: () => void;
|
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 {
|
function toDateOnly(dt: string): string {
|
||||||
if (!dt) return '';
|
if (!dt) return '';
|
||||||
return dt.split('T')[0];
|
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 {
|
function toDatetimeLocal(dt: string, fallbackTime: string = '09:00'): string {
|
||||||
if (!dt) return '';
|
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}`;
|
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 {
|
function formatForInput(dt: string, allDay: boolean, fallbackTime: string = '09:00'): string {
|
||||||
if (!dt) return '';
|
if (!dt) return '';
|
||||||
return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime);
|
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) {
|
export default function EventForm({ event, initialStart, initialEnd, initialAllDay, onClose }: EventFormProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { data: calendars = [] } = useCalendars();
|
||||||
const isAllDay = event?.all_day ?? initialAllDay ?? false;
|
const isAllDay = event?.all_day ?? initialAllDay ?? false;
|
||||||
const rawStart = event?.start_datetime || initialStart || '';
|
const rawStart = event?.start_datetime || initialStart || '';
|
||||||
const rawEnd = event?.end_datetime || initialEnd || '';
|
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({
|
const [formData, setFormData] = useState({
|
||||||
title: event?.title || '',
|
title: event?.title || '',
|
||||||
description: event?.description || '',
|
description: event?.description || '',
|
||||||
@ -68,7 +60,7 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
|
|||||||
end_datetime: formatForInput(rawEnd, isAllDay, '10:00'),
|
end_datetime: formatForInput(rawEnd, isAllDay, '10:00'),
|
||||||
all_day: isAllDay,
|
all_day: isAllDay,
|
||||||
location_id: event?.location_id?.toString() || '',
|
location_id: event?.location_id?.toString() || '',
|
||||||
color: event?.color || '',
|
calendar_id: initialCalendarId,
|
||||||
recurrence_rule: event?.recurrence_rule || '',
|
recurrence_rule: event?.recurrence_rule || '',
|
||||||
is_starred: event?.is_starred || false,
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: async (data: typeof formData) => {
|
mutationFn: async (data: typeof formData) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
...data,
|
...data,
|
||||||
location_id: data.location_id ? parseInt(data.location_id) : null,
|
location_id: data.location_id ? parseInt(data.location_id) : null,
|
||||||
|
calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null,
|
||||||
};
|
};
|
||||||
if (event) {
|
if (event) {
|
||||||
const response = await api.put(`/events/${event.id}`, payload);
|
const response = await api.put(`/events/${event.id}`, payload);
|
||||||
@ -196,6 +192,21 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="calendar">Calendar</Label>
|
||||||
|
<Select
|
||||||
|
id="calendar"
|
||||||
|
value={formData.calendar_id}
|
||||||
|
onChange={(e) => setFormData({ ...formData, calendar_id: e.target.value })}
|
||||||
|
>
|
||||||
|
{selectableCalendars.map((cal) => (
|
||||||
|
<option key={cal.id} value={cal.id}>
|
||||||
|
{cal.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="location">Location</Label>
|
<Label htmlFor="location">Location</Label>
|
||||||
<Select
|
<Select
|
||||||
@ -212,21 +223,6 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="color">Color</Label>
|
|
||||||
<Select
|
|
||||||
id="color"
|
|
||||||
value={formData.color}
|
|
||||||
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
|
||||||
>
|
|
||||||
{colorPresets.map((preset) => (
|
|
||||||
<option key={preset.name} value={preset.value}>
|
|
||||||
{preset.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="recurrence">Recurrence</Label>
|
<Label htmlFor="recurrence">Recurrence</Label>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
13
frontend/src/hooks/useCalendars.ts
Normal file
13
frontend/src/hooks/useCalendars.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { Calendar } from '@/types';
|
||||||
|
|
||||||
|
export function useCalendars() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['calendars'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<Calendar[]>('/calendars');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -97,6 +97,13 @@
|
|||||||
--fc-neutral-bg-color: hsl(0 0% 5%);
|
--fc-neutral-bg-color: hsl(0 0% 5%);
|
||||||
--fc-neutral-text-color: hsl(0 0% 98%);
|
--fc-neutral-text-color: hsl(0 0% 98%);
|
||||||
--fc-list-event-hover-bg-color: hsl(0 0% 10%);
|
--fc-list-event-hover-bg-color: hsl(0 0% 10%);
|
||||||
|
--fc-today-bg-color: hsl(var(--accent-color) / 0.08);
|
||||||
|
--fc-now-indicator-color: hsl(var(--accent-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide default FC toolbar (we use a custom React toolbar) */
|
||||||
|
.fc .fc-header-toolbar {
|
||||||
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc .fc-button-primary {
|
.fc .fc-button-primary {
|
||||||
@ -116,8 +123,24 @@
|
|||||||
border-color: var(--fc-button-active-border-color);
|
border-color: var(--fc-button-active-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Today highlight — accent tint */
|
||||||
.fc .fc-daygrid-day.fc-day-today {
|
.fc .fc-daygrid-day.fc-day-today {
|
||||||
background-color: hsl(0 0% 8%) !important;
|
background-color: hsl(var(--accent-color) / 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc .fc-timegrid-col.fc-day-today {
|
||||||
|
background-color: hsl(var(--accent-color) / 0.06) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Now indicator — accent colored line */
|
||||||
|
.fc .fc-timegrid-now-indicator-line {
|
||||||
|
border-color: hsl(var(--accent-color));
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc .fc-timegrid-now-indicator-arrow {
|
||||||
|
border-top-color: hsl(var(--accent-color));
|
||||||
|
border-bottom-color: hsl(var(--accent-color));
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc .fc-col-header-cell {
|
.fc .fc-col-header-cell {
|
||||||
@ -146,3 +169,25 @@
|
|||||||
.fc .fc-timegrid-slot-label {
|
.fc .fc-timegrid-slot-label {
|
||||||
color: hsl(0 0% 63.9%);
|
color: hsl(0 0% 63.9%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Event pills — compact rounded style */
|
||||||
|
.fc .fc-daygrid-event {
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 1px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc .fc-timegrid-event {
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc .fc-timegrid-event .fc-event-main {
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Day number styling for today */
|
||||||
|
.fc .fc-day-today .fc-daygrid-day-number {
|
||||||
|
color: hsl(var(--accent-color));
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
@ -33,8 +33,19 @@ export interface Todo {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CalendarEvent {
|
export interface Calendar {
|
||||||
id: number;
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
is_default: boolean;
|
||||||
|
is_system: boolean;
|
||||||
|
is_visible: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarEvent {
|
||||||
|
id: number | string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
start_datetime: string;
|
start_datetime: string;
|
||||||
@ -42,6 +53,10 @@ export interface CalendarEvent {
|
|||||||
all_day: boolean;
|
all_day: boolean;
|
||||||
location_id?: number;
|
location_id?: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
calendar_id: number;
|
||||||
|
calendar_name: string;
|
||||||
|
calendar_color: string;
|
||||||
|
is_virtual?: boolean;
|
||||||
recurrence_rule?: string;
|
recurrence_rule?: string;
|
||||||
is_starred?: boolean;
|
is_starred?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user