2026-08-01 12:28:37 +00:00
|
|
|
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
2026-08-01 02:00:24 +00:00
|
|
|
import { createRoute } from "@tanstack/react-router";
|
|
|
|
|
import { Route as appRoute } from "../_app";
|
2026-08-10 08:53:18 +00:00
|
|
|
import { useQueryClient, useMutation } from "@tanstack/react-query";
|
2026-08-01 02:10:18 +00:00
|
|
|
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
2026-08-10 08:53:18 +00:00
|
|
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
2026-08-01 02:10:18 +00:00
|
|
|
import { useRealtime } from "@/hooks/use-realtime";
|
2026-08-01 02:34:37 +00:00
|
|
|
import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react";
|
2026-08-10 08:53:18 +00:00
|
|
|
import { toast } from "sonner";
|
2026-08-01 02:10:18 +00:00
|
|
|
import { Button } from "@/components/ui/button";
|
2026-08-10 08:53:18 +00:00
|
|
|
import { LoadingState, EmptyState } from "@/components/state";
|
2026-08-01 02:10:18 +00:00
|
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
2026-08-01 02:34:37 +00:00
|
|
|
import type { CalendarEvent } from "@/lib/types";
|
|
|
|
|
import { format, parseISO, addDays, startOfWeek, getDay } from "date-fns";
|
|
|
|
|
|
|
|
|
|
// react-big-calendar
|
2026-08-10 08:53:18 +00:00
|
|
|
import { Calendar, dateFnsLocalizer, Navigate, type View, type stringOrDate } from "react-big-calendar";
|
|
|
|
|
import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop";
|
2026-08-01 02:34:37 +00:00
|
|
|
import "react-big-calendar/lib/css/react-big-calendar.css";
|
2026-08-10 08:53:18 +00:00
|
|
|
import "react-big-calendar/lib/addons/dragAndDrop/styles.css";
|
2026-08-01 02:34:37 +00:00
|
|
|
|
|
|
|
|
const localizer = dateFnsLocalizer({
|
|
|
|
|
startOfWeek,
|
|
|
|
|
getDay,
|
|
|
|
|
format,
|
|
|
|
|
locales: {},
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-10 08:53:18 +00:00
|
|
|
// Mapped calendar event shape used by react-big-calendar (adds Date start/end accessors)
|
|
|
|
|
type CalendarViewEvent = CalendarEvent & { start: Date; end: Date };
|
|
|
|
|
|
|
|
|
|
const DragAndDropCalendar = withDragAndDrop<CalendarViewEvent>(Calendar);
|
2026-08-01 02:34:37 +00:00
|
|
|
|
|
|
|
|
// Color palette: tasks=accent, events by domain
|
|
|
|
|
const EVENT_COLORS: Record<string, string> = {
|
|
|
|
|
task: "var(--accent-hsl, 217 91% 60%)",
|
|
|
|
|
habit: "142 71% 45%",
|
|
|
|
|
project: "271 81% 56%",
|
|
|
|
|
note: "24 95% 53%",
|
|
|
|
|
default: "215 16% 47%",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function eventColor(event: CalendarEvent): string {
|
|
|
|
|
const hue = event.entityType ? EVENT_COLORS[event.entityType] || EVENT_COLORS.default : EVENT_COLORS.default;
|
|
|
|
|
return `hsl(${hue})`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function eventBg(event: CalendarEvent): string {
|
|
|
|
|
const hue = event.entityType ? EVENT_COLORS[event.entityType] || EVENT_COLORS.default : EVENT_COLORS.default;
|
|
|
|
|
return `hsla(${hue}, 0.15)`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Custom event component
|
|
|
|
|
function EventComponent({ event }: { event: CalendarEvent }) {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className="rbc-event-content truncate px-1 py-0.5 text-xs"
|
|
|
|
|
style={{
|
|
|
|
|
borderLeft: `3px solid ${eventColor(event)}`,
|
|
|
|
|
backgroundColor: eventBg(event),
|
|
|
|
|
color: eventColor(event),
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{event.title}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Custom toolbar
|
2026-08-01 12:46:41 +00:00
|
|
|
function CustomToolbar({ date, onNavigate, label }: any) {
|
2026-08-01 02:34:37 +00:00
|
|
|
const goToBack = () => onNavigate(Navigate.PREVIOUS);
|
|
|
|
|
const goToNext = () => onNavigate(Navigate.NEXT);
|
|
|
|
|
const goToToday = () => onNavigate(Navigate.TODAY);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Button variant="outline" size="icon" onClick={goToBack} aria-label="Previous">
|
|
|
|
|
<ChevronLeft className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="outline" size="sm" onClick={goToToday}>
|
|
|
|
|
Today
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="outline" size="icon" onClick={goToNext} aria-label="Next">
|
|
|
|
|
<ChevronRight className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<span className="ml-2 text-lg font-semibold">{label}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-08-01 02:10:18 +00:00
|
|
|
|
|
|
|
|
function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => void }) {
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
const [title, setTitle] = useState(event?.title || "");
|
|
|
|
|
const [startTime, setStartTime] = useState(event?.startTime ? event.startTime.slice(0, 16) : "");
|
|
|
|
|
const [endTime, setEndTime] = useState(event?.endTime ? event.endTime.slice(0, 16) : "");
|
|
|
|
|
const [allDay, setAllDay] = useState(event?.allDay || false);
|
|
|
|
|
const [color, setColor] = useState(event?.color || "#3b82f6");
|
|
|
|
|
|
2026-08-01 02:34:37 +00:00
|
|
|
const createMutation = useApiMutation<CalendarEvent, any>("post", "/calendar/events", {
|
2026-08-10 08:53:18 +00:00
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
|
|
|
|
toast.success("Event created");
|
|
|
|
|
onClose();
|
|
|
|
|
},
|
|
|
|
|
onError: (err) => toast.error(err.message || "Failed to create event"),
|
2026-08-01 02:10:18 +00:00
|
|
|
});
|
|
|
|
|
|
2026-08-01 02:34:37 +00:00
|
|
|
const updateMutation = useApiMutation<CalendarEvent, any>("patch", event ? `/calendar/events/${event.id}` : "", {
|
2026-08-10 08:53:18 +00:00
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
|
|
|
|
toast.success("Event updated");
|
|
|
|
|
onClose();
|
|
|
|
|
},
|
|
|
|
|
onError: (err) => toast.error(err.message || "Failed to update event"),
|
2026-08-01 02:10:18 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!title.trim()) return;
|
|
|
|
|
const data: any = { title: title.trim(), allDay, color };
|
|
|
|
|
if (startTime) data.startTime = new Date(startTime).toISOString();
|
|
|
|
|
if (endTime) data.endTime = new Date(endTime).toISOString();
|
|
|
|
|
if (event) updateMutation.mutate(data);
|
|
|
|
|
else createMutation.mutate(data);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="title">Title</Label>
|
|
|
|
|
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Event title" required />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<input type="checkbox" id="allDay" checked={allDay} onChange={(e) => setAllDay(e.target.checked)} className="rounded" />
|
|
|
|
|
<Label htmlFor="allDay">All day</Label>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="start">Start</Label>
|
|
|
|
|
<Input id="start" type={allDay ? "date" : "datetime-local"} value={startTime} onChange={(e) => setStartTime(e.target.value)} />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="end">End</Label>
|
|
|
|
|
<Input id="end" type={allDay ? "date" : "datetime-local"} value={endTime} onChange={(e) => setEndTime(e.target.value)} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="color">Color</Label>
|
|
|
|
|
<Input id="color" type="color" value={color} onChange={(e) => setColor(e.target.value)} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-end gap-2">
|
|
|
|
|
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
|
|
|
|
<Button type="submit">{event ? "Update" : "Create"} Event</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-08-01 02:00:24 +00:00
|
|
|
|
|
|
|
|
function CalendarPage() {
|
2026-08-01 02:10:18 +00:00
|
|
|
const queryClient = useQueryClient();
|
2026-08-01 02:34:37 +00:00
|
|
|
const [date, setDate] = useState(new Date());
|
2026-08-10 08:53:18 +00:00
|
|
|
const [view, setView] = useState<View>("month");
|
2026-08-01 02:10:18 +00:00
|
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
|
|
|
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
|
|
|
|
|
const [eventDetailOpen, setEventDetailOpen] = useState(false);
|
2026-08-01 02:34:37 +00:00
|
|
|
const [isMobile, setIsMobile] = useState(false);
|
2026-08-01 02:10:18 +00:00
|
|
|
|
|
|
|
|
useRealtime({ enabled: true });
|
|
|
|
|
|
2026-08-01 02:34:37 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
const check = () => setIsMobile(window.innerWidth < 768);
|
|
|
|
|
check();
|
|
|
|
|
window.addEventListener("resize", check);
|
|
|
|
|
return () => window.removeEventListener("resize", check);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-08-01 02:10:18 +00:00
|
|
|
|
2026-08-10 08:53:18 +00:00
|
|
|
const activeDomainId = useApiDomain();
|
|
|
|
|
|
|
|
|
|
const { data: eventsData, isLoading: eventsLoading } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>(
|
|
|
|
|
["calendar-events", activeDomainId, date.toISOString()],
|
|
|
|
|
`/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}` + (activeDomainId ? "&domain=" + activeDomainId : "")
|
2026-08-01 02:10:18 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const events = eventsData?.items || [];
|
|
|
|
|
|
2026-08-10 08:53:18 +00:00
|
|
|
const deleteMutation = useMutation({
|
|
|
|
|
mutationFn: (id: string) => api.delete("/calendar/events/" + id),
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
|
|
|
|
setEventDetailOpen(false);
|
|
|
|
|
toast.success("Event deleted");
|
|
|
|
|
},
|
|
|
|
|
onError: (err) => toast.error(err.message || "Failed to delete event"),
|
2026-08-01 02:10:18 +00:00
|
|
|
});
|
|
|
|
|
|
2026-08-01 02:34:37 +00:00
|
|
|
// Map API events to react-big-calendar format
|
2026-08-10 08:53:18 +00:00
|
|
|
const calendarEvents = useMemo<CalendarViewEvent[]>(() => {
|
2026-08-01 02:34:37 +00:00
|
|
|
return events.map((evt) => ({
|
|
|
|
|
...evt,
|
|
|
|
|
start: parseISO(evt.startTime),
|
|
|
|
|
end: evt.endTime ? parseISO(evt.endTime) : addDays(parseISO(evt.startTime), 1),
|
|
|
|
|
}));
|
2026-08-01 02:10:18 +00:00
|
|
|
}, [events]);
|
|
|
|
|
|
2026-08-01 02:34:37 +00:00
|
|
|
const handleSelectEvent = useCallback((event: any) => {
|
|
|
|
|
setSelectedEvent(event as CalendarEvent);
|
|
|
|
|
setEventDetailOpen(true);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleSelectSlot = useCallback(() => {
|
|
|
|
|
setCreateOpen(true);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleEventDrop = useCallback(
|
2026-08-10 08:53:18 +00:00
|
|
|
({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => {
|
2026-08-01 02:34:37 +00:00
|
|
|
api.patch(`/calendar/events/${event.id}`, {
|
2026-08-10 08:53:18 +00:00
|
|
|
startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(),
|
|
|
|
|
endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(),
|
2026-08-01 02:34:37 +00:00
|
|
|
}).then(() => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[queryClient]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleEventResize = useCallback(
|
2026-08-10 08:53:18 +00:00
|
|
|
({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => {
|
2026-08-01 02:34:37 +00:00
|
|
|
api.patch(`/calendar/events/${event.id}`, {
|
2026-08-10 08:53:18 +00:00
|
|
|
startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(),
|
|
|
|
|
endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(),
|
2026-08-01 02:34:37 +00:00
|
|
|
}).then(() => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[queryClient]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleNavigate = useCallback((newDate: Date) => {
|
|
|
|
|
setDate(newDate);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleViewChange = useCallback((newView: string) => {
|
2026-08-10 08:53:18 +00:00
|
|
|
setView(newView as View);
|
2026-08-01 02:34:37 +00:00
|
|
|
}, []);
|
|
|
|
|
|
2026-08-01 02:00:24 +00:00
|
|
|
return (
|
2026-08-01 02:10:18 +00:00
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<h1 className="text-2xl font-bold">Calendar</h1>
|
|
|
|
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
|
|
|
<DialogTrigger asChild>
|
|
|
|
|
<Button aria-label="New event"><Plus className="h-4 w-4 mr-2" />New Event</Button>
|
|
|
|
|
</DialogTrigger>
|
|
|
|
|
<DialogContent>
|
|
|
|
|
<DialogHeader><DialogTitle>New Event</DialogTitle></DialogHeader>
|
|
|
|
|
<EventForm onClose={() => setCreateOpen(false)} />
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-08-01 12:46:41 +00:00
|
|
|
<div className="flex items-center justify-end gap-1 mb-2">
|
|
|
|
|
{["month", "week", "day", "agenda"].map((name) => (
|
|
|
|
|
<Button
|
|
|
|
|
key={name}
|
|
|
|
|
variant={view === name ? "default" : "outline"}
|
|
|
|
|
size="sm"
|
2026-08-10 08:53:18 +00:00
|
|
|
onClick={() => setView(name as View)}
|
2026-08-01 12:46:41 +00:00
|
|
|
className="capitalize"
|
|
|
|
|
>
|
|
|
|
|
{name}
|
|
|
|
|
</Button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-08-10 08:53:18 +00:00
|
|
|
{eventsLoading && events.length === 0 ? (
|
|
|
|
|
<LoadingState label="Loading events..." />
|
|
|
|
|
) : !eventsLoading && events.length === 0 ? (
|
|
|
|
|
<EmptyState
|
|
|
|
|
title="No events yet"
|
|
|
|
|
description="Create your first event to get started"
|
|
|
|
|
action={
|
|
|
|
|
<Button onClick={() => setCreateOpen(true)}>
|
|
|
|
|
<Plus className="h-4 w-4 mr-2" />New Event
|
|
|
|
|
</Button>
|
|
|
|
|
}
|
2026-08-01 12:46:41 +00:00
|
|
|
/>
|
2026-08-10 08:53:18 +00:00
|
|
|
) : (
|
|
|
|
|
<div className="rbc-calendar-container" style={{ minHeight: isMobile ? 400 : 600 }}>
|
|
|
|
|
<DragAndDropCalendar
|
|
|
|
|
key={view}
|
|
|
|
|
localizer={localizer}
|
|
|
|
|
events={calendarEvents}
|
|
|
|
|
startAccessor="start"
|
|
|
|
|
endAccessor="end"
|
|
|
|
|
date={date}
|
|
|
|
|
view={view}
|
|
|
|
|
onNavigate={handleNavigate}
|
|
|
|
|
onSelectEvent={handleSelectEvent}
|
|
|
|
|
onSelectSlot={handleSelectSlot}
|
|
|
|
|
onEventDrop={handleEventDrop}
|
|
|
|
|
onEventResize={handleEventResize}
|
|
|
|
|
selectable
|
|
|
|
|
popup
|
|
|
|
|
showMultiDayTimes
|
|
|
|
|
components={{
|
|
|
|
|
event: EventComponent,
|
|
|
|
|
toolbar: (props: any) => <CustomToolbar {...props} />,
|
|
|
|
|
}}
|
|
|
|
|
views={["month", "week", "work_week", "day", "agenda"]}
|
|
|
|
|
step={30}
|
|
|
|
|
timeslots={2}
|
|
|
|
|
style={{ height: isMobile ? 400 : 600 }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-08-01 02:10:18 +00:00
|
|
|
|
|
|
|
|
{/* Event detail dialog */}
|
|
|
|
|
<Dialog open={eventDetailOpen} onOpenChange={setEventDetailOpen}>
|
|
|
|
|
<DialogContent>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>{selectedEvent?.title || "Event"}</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
{selectedEvent && (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="text-sm">
|
|
|
|
|
<p><strong>Start:</strong> {new Date(selectedEvent.startTime).toLocaleString()}</p>
|
|
|
|
|
{selectedEvent.endTime && <p><strong>End:</strong> {new Date(selectedEvent.endTime).toLocaleString()}</p>}
|
|
|
|
|
{selectedEvent.description && <p className="mt-2">{selectedEvent.description}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
<EventForm event={selectedEvent} onClose={() => setEventDetailOpen(false)} />
|
|
|
|
|
<AlertDialog>
|
|
|
|
|
<AlertDialogTrigger asChild>
|
|
|
|
|
<Button variant="destructive" size="sm"><Trash2 className="h-4 w-4 mr-2" />Delete Event</Button>
|
|
|
|
|
</AlertDialogTrigger>
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
<AlertDialogTitle>Delete Event</AlertDialogTitle>
|
|
|
|
|
<AlertDialogDescription>Are you sure you want to delete "{selectedEvent.title}"?</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
|
|
|
<AlertDialogAction onClick={() => deleteMutation.mutate(selectedEvent.id)} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
2026-08-01 02:00:24 +00:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const Route = createRoute({
|
|
|
|
|
getParentRoute: () => appRoute,
|
|
|
|
|
path: "/calendar",
|
|
|
|
|
component: CalendarPage,
|
|
|
|
|
});
|