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 { 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<CalendarView, string> = {
|
||||
dayGridMonth: 'Month',
|
||||
timeGridWeek: 'Week',
|
||||
timeGridDay: 'Day',
|
||||
};
|
||||
|
||||
export default function CalendarPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const calendarRef = useRef<FullCalendar>(null);
|
||||
@ -18,6 +30,10 @@ export default function CalendarPage() {
|
||||
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 { 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<CalendarEvent[]>(['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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="border-b bg-card px-6 py-4">
|
||||
<h1 className="text-3xl font-bold">Calendar</h1>
|
||||
</div>
|
||||
const handleDatesSet = (arg: DatesSetArg) => {
|
||||
setCalendarTitle(arg.view.title);
|
||||
setCurrentView(arg.view.type as CalendarView);
|
||||
};
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="bg-card rounded-lg border p-4">
|
||||
<FullCalendar
|
||||
ref={calendarRef}
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
headerToolbar={{
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay',
|
||||
}}
|
||||
events={calendarEvents}
|
||||
editable={true}
|
||||
selectable={true}
|
||||
selectMirror={true}
|
||||
unselectAuto={false}
|
||||
dayMaxEvents={true}
|
||||
weekends={true}
|
||||
eventClick={handleEventClick}
|
||||
eventDrop={handleEventDrop}
|
||||
eventResize={handleEventResize}
|
||||
select={handleDateSelect}
|
||||
height="auto"
|
||||
/>
|
||||
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 />
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Custom toolbar */}
|
||||
<div className="border-b bg-card px-6 py-3 flex items-center gap-4">
|
||||
<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 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>
|
||||
|
||||
|
||||
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 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
|
||||
</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">
|
||||
<Label htmlFor="location">Location</Label>
|
||||
<Select
|
||||
@ -212,21 +223,6 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
|
||||
</Select>
|
||||
</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">
|
||||
<Label htmlFor="recurrence">Recurrence</Label>
|
||||
<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-text-color: hsl(0 0% 98%);
|
||||
--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 {
|
||||
@ -116,8 +123,24 @@
|
||||
border-color: var(--fc-button-active-border-color);
|
||||
}
|
||||
|
||||
/* Today highlight — accent tint */
|
||||
.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 {
|
||||
@ -146,3 +169,25 @@
|
||||
.fc .fc-timegrid-slot-label {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
export interface Calendar {
|
||||
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;
|
||||
description?: string;
|
||||
start_datetime: string;
|
||||
@ -42,6 +53,10 @@ export interface CalendarEvent {
|
||||
all_day: boolean;
|
||||
location_id?: number;
|
||||
color?: string;
|
||||
calendar_id: number;
|
||||
calendar_name: string;
|
||||
calendar_color: string;
|
||||
is_virtual?: boolean;
|
||||
recurrence_rule?: string;
|
||||
is_starred?: boolean;
|
||||
created_at: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user