1372 lines
45 KiB
TypeScript
1372 lines
45 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useEffect } from "react";
|
|
import FullCalendar from "@fullcalendar/react";
|
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
|
import timeGridPlugin from "@fullcalendar/timegrid";
|
|
import interactionPlugin from "@fullcalendar/interaction";
|
|
import frLocale from "@fullcalendar/core/locales/fr";
|
|
import { Card } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import {
|
|
Loader2,
|
|
Plus,
|
|
Calendar as CalendarIcon,
|
|
Check,
|
|
X,
|
|
User,
|
|
Clock,
|
|
BarChart2,
|
|
Settings,
|
|
ChevronRight,
|
|
ChevronLeft,
|
|
Bell,
|
|
Users,
|
|
MapPin,
|
|
Tag,
|
|
ChevronDown,
|
|
ChevronUp
|
|
} from "lucide-react";
|
|
import { Calendar, Event } from "@prisma/client";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import DatePicker, { registerLocale } from "react-datepicker";
|
|
import "react-datepicker/dist/react-datepicker.css";
|
|
import { fr } from "date-fns/locale";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
|
|
// Register French locale
|
|
registerLocale('fr', fr);
|
|
|
|
// Predefined professional color palette
|
|
const colorPalette = [
|
|
"#4f46e5", // Indigo
|
|
"#0891b2", // Cyan
|
|
"#0e7490", // Teal
|
|
"#16a34a", // Green
|
|
"#65a30d", // Lime
|
|
"#ca8a04", // Amber
|
|
"#d97706", // Orange
|
|
"#dc2626", // Red
|
|
"#e11d48", // Rose
|
|
"#9333ea", // Purple
|
|
"#7c3aed", // Violet
|
|
"#2563eb", // Blue
|
|
];
|
|
|
|
interface CalendarClientProps {
|
|
initialCalendars: (Calendar & { events: Event[] })[];
|
|
userId: string;
|
|
userProfile: {
|
|
name: string;
|
|
email: string;
|
|
avatar?: string;
|
|
};
|
|
}
|
|
|
|
interface EventFormData {
|
|
title: string;
|
|
description: string | null;
|
|
start: string;
|
|
end: string;
|
|
allDay: boolean;
|
|
location: string | null;
|
|
calendarId?: string;
|
|
}
|
|
|
|
interface CalendarDialogProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onSave: (calendarData: Partial<Calendar>) => Promise<void>;
|
|
onDelete?: (calendarId: string) => Promise<void>;
|
|
initialData?: Partial<Calendar>;
|
|
}
|
|
|
|
function CalendarDialog({ open, onClose, onSave, onDelete, initialData }: CalendarDialogProps) {
|
|
const [name, setName] = useState(initialData?.name || "");
|
|
const [color, setColor] = useState(initialData?.color || "#4f46e5");
|
|
const [description, setDescription] = useState(initialData?.description || "");
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [customColorMode, setCustomColorMode] = useState(false);
|
|
|
|
const isMainCalendar = initialData?.name === "Calendrier principal";
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setName(initialData?.name || "");
|
|
setColor(initialData?.color || "#4f46e5");
|
|
setDescription(initialData?.description || "");
|
|
setCustomColorMode(!colorPalette.includes(initialData?.color || "#4f46e5"));
|
|
}
|
|
}, [open, initialData]);
|
|
|
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
setIsSubmitting(true);
|
|
|
|
try {
|
|
await onSave({
|
|
id: initialData?.id,
|
|
name,
|
|
color,
|
|
description
|
|
});
|
|
resetForm();
|
|
} catch (error) {
|
|
console.error("Erreur lors de la création du calendrier:", error);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!initialData?.id || !onDelete || isMainCalendar) return;
|
|
|
|
if (!confirm("Êtes-vous sûr de vouloir supprimer ce calendrier ? Tous les événements associés seront également supprimés.")) {
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
await onDelete(initialData.id);
|
|
resetForm();
|
|
} catch (error) {
|
|
console.error("Erreur lors de la suppression du calendrier:", error);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setName("");
|
|
setColor("#4f46e5");
|
|
setDescription("");
|
|
setCustomColorMode(false);
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
|
<DialogContent className="sm:max-w-md rounded-xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center text-xl font-semibold text-gray-900">
|
|
<CalendarIcon className="w-5 h-5 mr-2 text-indigo-600" />
|
|
{initialData?.id ? "Modifier le calendrier" : "Créer un nouveau calendrier"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="space-y-5 py-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="calendar-name" className="text-gray-700">Nom</Label>
|
|
<Input
|
|
id="calendar-name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="Nom du calendrier"
|
|
required
|
|
disabled={isMainCalendar}
|
|
className="rounded-lg border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<Label className="text-gray-700">Couleur</Label>
|
|
|
|
<div className="flex flex-wrap gap-3 mb-3">
|
|
{colorPalette.map((paletteColor) => (
|
|
<button
|
|
key={paletteColor}
|
|
type="button"
|
|
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all ${
|
|
color === paletteColor && !customColorMode
|
|
? 'ring-2 ring-offset-2 ring-gray-400'
|
|
: 'hover:scale-110'
|
|
}`}
|
|
style={{ backgroundColor: paletteColor }}
|
|
onClick={() => {
|
|
setColor(paletteColor);
|
|
setCustomColorMode(false);
|
|
}}
|
|
>
|
|
{color === paletteColor && !customColorMode && (
|
|
<Check className="w-4 h-4 text-white" />
|
|
)}
|
|
</button>
|
|
))}
|
|
|
|
<button
|
|
type="button"
|
|
className={`w-8 h-8 rounded-full flex items-center justify-center bg-gradient-to-r from-purple-500 via-pink-500 to-red-500 transition-all ${
|
|
customColorMode
|
|
? 'ring-2 ring-offset-2 ring-gray-400'
|
|
: 'hover:scale-110'
|
|
}`}
|
|
onClick={() => setCustomColorMode(true)}
|
|
>
|
|
{customColorMode && <Check className="w-4 h-4 text-white" />}
|
|
</button>
|
|
</div>
|
|
|
|
{customColorMode && (
|
|
<div className="flex items-center gap-4 mt-2 p-3 bg-gray-50 rounded-lg">
|
|
<div className="flex flex-1 items-center gap-3">
|
|
<Input
|
|
id="calendar-color"
|
|
type="color"
|
|
value={color}
|
|
onChange={(e) => setColor(e.target.value)}
|
|
className="w-10 h-10 p-1 cursor-pointer rounded border-gray-300"
|
|
/>
|
|
<Input
|
|
type="text"
|
|
value={color}
|
|
onChange={(e) => setColor(e.target.value)}
|
|
placeholder="#RRGGBB"
|
|
className="w-28 rounded-lg"
|
|
/>
|
|
</div>
|
|
<div
|
|
className="w-8 h-8 rounded-lg shadow-sm"
|
|
style={{ backgroundColor: color }}
|
|
></div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="calendar-description" className="text-gray-700">
|
|
Description (optionnelle)
|
|
</Label>
|
|
<Textarea
|
|
id="calendar-description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="Description du calendrier"
|
|
rows={3}
|
|
className="rounded-lg border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="mt-6 border-t border-gray-100 pt-4">
|
|
<div className="flex justify-between w-full">
|
|
{initialData?.id && !isMainCalendar && (
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={handleDelete}
|
|
disabled={isSubmitting || isMainCalendar}
|
|
>
|
|
<X className="w-4 h-4 mr-2" />
|
|
Supprimer
|
|
</Button>
|
|
)}
|
|
<div className="flex gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={onClose}
|
|
disabled={isSubmitting}
|
|
className="rounded-lg border-gray-300 text-gray-700 hover:bg-gray-50"
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={!name || isSubmitting || isMainCalendar}
|
|
className="rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white"
|
|
>
|
|
{isSubmitting
|
|
? "Enregistrement..."
|
|
: initialData?.id
|
|
? "Mettre à jour"
|
|
: "Créer"
|
|
}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function StatisticsPanel({ statistics }: {
|
|
statistics: {
|
|
totalEvents: number;
|
|
upcomingEvents: number;
|
|
completedEvents: number;
|
|
meetingHours: number;
|
|
};
|
|
}) {
|
|
return (
|
|
<div className="grid grid-cols-2 gap-4 p-4">
|
|
<Card className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-primary/10 rounded-full">
|
|
<CalendarIcon className="h-5 w-5 text-primary" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium">Total Événements</p>
|
|
<p className="text-2xl font-bold">{statistics.totalEvents}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-primary/10 rounded-full">
|
|
<Clock className="h-5 w-5 text-primary" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium">Heures de Réunion</p>
|
|
<p className="text-2xl font-bold">{statistics.meetingHours}h</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-primary/10 rounded-full">
|
|
<Bell className="h-5 w-5 text-primary" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium">Prochains Événements</p>
|
|
<p className="text-2xl font-bold">{statistics.upcomingEvents}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-primary/10 rounded-full">
|
|
<Check className="h-5 w-5 text-primary" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium">Événements Terminés</p>
|
|
<p className="text-2xl font-bold">{statistics.completedEvents}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EventPreview({ event, calendar }: { event: Event; calendar: Calendar }) {
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
return (
|
|
<Card className="p-4 space-y-4">
|
|
<div className="flex items-start justify-between">
|
|
<div className="space-y-1">
|
|
<h3 className="font-medium">{event.title}</h3>
|
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
|
<Clock className="h-4 w-4" />
|
|
<span>
|
|
{new Date(event.start).toLocaleDateString('fr-FR', {
|
|
weekday: 'long',
|
|
day: 'numeric',
|
|
month: 'long',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="w-3 h-3 rounded-full"
|
|
style={{ backgroundColor: calendar.color }}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
>
|
|
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="space-y-4">
|
|
{event.description && (
|
|
<p className="text-sm text-gray-600">{event.description}</p>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
{event.location && (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<MapPin className="h-4 w-4 text-gray-500" />
|
|
<span>{event.location}</span>
|
|
</div>
|
|
)}
|
|
{event.calendarId && (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Tag className="h-4 w-4 text-gray-500" />
|
|
<span>{calendar.name}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" className="flex-1">
|
|
Modifier
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="flex-1">
|
|
Supprimer
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function CalendarClient({ initialCalendars, userId, userProfile }: CalendarClientProps) {
|
|
const [calendars, setCalendars] = useState(initialCalendars.map(cal => ({
|
|
...cal,
|
|
events: cal.events || []
|
|
})));
|
|
const [selectedCalendarId, setSelectedCalendarId] = useState<string>(
|
|
initialCalendars[0]?.id || ""
|
|
);
|
|
const [view, setView] = useState<"dayGridMonth" | "timeGridWeek" | "timeGridDay">("dayGridMonth");
|
|
const [isEventModalOpen, setIsEventModalOpen] = useState(false);
|
|
const [isCalendarModalOpen, setIsCalendarModalOpen] = useState(false);
|
|
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
|
const [selectedCalendar, setSelectedCalendar] = useState<Calendar | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [eventForm, setEventForm] = useState<EventFormData>({
|
|
title: "",
|
|
description: null,
|
|
start: "",
|
|
end: "",
|
|
allDay: false,
|
|
location: null,
|
|
calendarId: selectedCalendarId
|
|
});
|
|
|
|
const [selectedEventPreview, setSelectedEventPreview] = useState<Event | null>(null);
|
|
const [upcomingEvents, setUpcomingEvents] = useState<Event[]>([]);
|
|
const [statistics, setStatistics] = useState({
|
|
totalEvents: 0,
|
|
upcomingEvents: 0,
|
|
completedEvents: 0,
|
|
meetingHours: 0
|
|
});
|
|
|
|
const [visibleCalendarIds, setVisibleCalendarIds] = useState<string[]>([]);
|
|
|
|
// Update useEffect to initialize visible calendars and fetch events
|
|
useEffect(() => {
|
|
if (calendars.length > 0) {
|
|
setVisibleCalendarIds(calendars.map(cal => cal.id));
|
|
updateStatistics();
|
|
updateUpcomingEvents();
|
|
}
|
|
}, [calendars]);
|
|
|
|
const updateStatistics = () => {
|
|
const now = new Date();
|
|
const stats = {
|
|
totalEvents: 0,
|
|
upcomingEvents: 0,
|
|
completedEvents: 0,
|
|
meetingHours: 0
|
|
};
|
|
|
|
calendars.forEach(cal => {
|
|
cal.events.forEach(event => {
|
|
stats.totalEvents++;
|
|
const eventStart = new Date(event.start);
|
|
const eventEnd = new Date(event.end);
|
|
|
|
if (eventStart > now) {
|
|
stats.upcomingEvents++;
|
|
} else if (eventEnd < now) {
|
|
stats.completedEvents++;
|
|
}
|
|
|
|
const duration = (eventEnd.getTime() - eventStart.getTime()) / (1000 * 60 * 60);
|
|
stats.meetingHours += duration;
|
|
});
|
|
});
|
|
|
|
setStatistics(stats);
|
|
};
|
|
|
|
const updateUpcomingEvents = () => {
|
|
const now = new Date();
|
|
const upcoming = calendars
|
|
.flatMap(cal => cal.events)
|
|
.filter(event => new Date(event.start) > now)
|
|
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
|
|
.slice(0, 5);
|
|
setUpcomingEvents(upcoming);
|
|
};
|
|
|
|
const fetchCalendars = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await fetch("/api/calendars");
|
|
if (!response.ok) throw new Error("Failed to fetch calendars");
|
|
const data = await response.json();
|
|
console.log("Raw calendars data:", data);
|
|
|
|
// Process calendars and events
|
|
const processedCalendars = data.map((cal: Calendar & { events: Event[] }) => ({
|
|
...cal,
|
|
events: Array.isArray(cal.events) ? cal.events.map(event => ({
|
|
...event,
|
|
start: new Date(event.start),
|
|
end: new Date(event.end)
|
|
})) : []
|
|
}));
|
|
|
|
console.log("Setting calendars with processed events:", processedCalendars);
|
|
setCalendars(processedCalendars);
|
|
|
|
// Update statistics and upcoming events
|
|
updateStatistics();
|
|
updateUpcomingEvents();
|
|
|
|
// Force calendar refresh
|
|
if (calendarRef.current) {
|
|
const calendarApi = calendarRef.current.getApi();
|
|
calendarApi.refetchEvents();
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching calendars:", error);
|
|
setError(error instanceof Error ? error.message : "Failed to fetch calendars");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const calendarRef = useRef<any>(null);
|
|
|
|
const handleCalendarSelect = (calendarId: string) => {
|
|
console.log("Calendar selected:", calendarId);
|
|
setSelectedCalendarId(calendarId);
|
|
setEventForm(prev => ({
|
|
...prev,
|
|
calendarId: calendarId
|
|
}));
|
|
};
|
|
|
|
const handleCalendarSave = async (calendarData: Partial<Calendar>) => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await fetch("/api/calendars", {
|
|
method: calendarData.id ? "PUT" : "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
...calendarData,
|
|
userId,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to save calendar");
|
|
}
|
|
|
|
await fetchCalendars();
|
|
setIsCalendarModalOpen(false);
|
|
} catch (error) {
|
|
console.error("Error saving calendar:", error);
|
|
setError(error instanceof Error ? error.message : "Failed to save calendar");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCalendarDelete = async (calendarId: string) => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await fetch(`/api/calendars/${calendarId}`, {
|
|
method: "DELETE",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to delete calendar");
|
|
}
|
|
|
|
await fetchCalendars();
|
|
setIsCalendarModalOpen(false);
|
|
|
|
// If the deleted calendar was selected, select another one
|
|
if (selectedCalendarId === calendarId) {
|
|
const remainingCalendars = calendars.filter(cal => cal.id !== calendarId);
|
|
setSelectedCalendarId(remainingCalendars[0]?.id || "");
|
|
}
|
|
} catch (error) {
|
|
console.error("Error deleting calendar:", error);
|
|
setError(error instanceof Error ? error.message : "Failed to delete calendar");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDateSelect = (selectInfo: any) => {
|
|
const startDate = new Date(selectInfo.start);
|
|
const endDate = new Date(selectInfo.end);
|
|
|
|
console.log("Date select handler - Current state:", {
|
|
calendars: calendars.map(c => ({ id: c.id, name: c.name })),
|
|
selectedCalendarId,
|
|
availableCalendars: calendars.length
|
|
});
|
|
|
|
// If no calendar is selected, use the first available calendar
|
|
if (!selectedCalendarId && calendars.length > 0) {
|
|
const firstCalendar = calendars[0];
|
|
console.log("No calendar selected, selecting first calendar:", firstCalendar);
|
|
setSelectedCalendarId(firstCalendar.id);
|
|
|
|
setEventForm({
|
|
title: "",
|
|
description: null,
|
|
start: startDate.toISOString(),
|
|
end: endDate.toISOString(),
|
|
allDay: selectInfo.allDay,
|
|
location: null,
|
|
calendarId: firstCalendar.id
|
|
});
|
|
} else {
|
|
setEventForm({
|
|
title: "",
|
|
description: null,
|
|
start: startDate.toISOString(),
|
|
end: endDate.toISOString(),
|
|
allDay: selectInfo.allDay,
|
|
location: null,
|
|
calendarId: selectedCalendarId
|
|
});
|
|
}
|
|
|
|
setIsEventModalOpen(true);
|
|
};
|
|
|
|
const handleEventClick = (clickInfo: any) => {
|
|
const event = clickInfo.event;
|
|
const startDate = new Date(event.start);
|
|
const endDate = new Date(event.end || event.start);
|
|
|
|
setSelectedEvent(event.extendedProps.originalEvent);
|
|
setEventForm({
|
|
title: event.title,
|
|
description: event.extendedProps.description,
|
|
start: startDate.toISOString().slice(0, 16),
|
|
end: endDate.toISOString().slice(0, 16),
|
|
allDay: event.isAllDay,
|
|
location: event.extendedProps.location,
|
|
calendarId: event.extendedProps.calendarId,
|
|
});
|
|
setIsEventModalOpen(true);
|
|
};
|
|
|
|
const handleEventSubmit = async () => {
|
|
try {
|
|
// Validate required fields including calendar
|
|
if (!eventForm.title || !eventForm.start || !eventForm.end || !eventForm.calendarId) {
|
|
console.log("Form validation failed:", {
|
|
title: eventForm.title,
|
|
start: eventForm.start,
|
|
end: eventForm.end,
|
|
calendarId: eventForm.calendarId
|
|
});
|
|
setError("Veuillez remplir tous les champs obligatoires et sélectionner un calendrier");
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
const eventData = {
|
|
...eventForm,
|
|
start: new Date(eventForm.start).toISOString(),
|
|
end: new Date(eventForm.end).toISOString(),
|
|
userId,
|
|
...(selectedEvent ? { id: selectedEvent.id } : {}), // Include ID for updates
|
|
allDay: eventForm.allDay // Use allDay instead of isAllDay
|
|
};
|
|
|
|
console.log("Submitting event with data:", eventData);
|
|
|
|
const response = await fetch("/api/events", {
|
|
method: selectedEvent ? "PUT" : "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(eventData),
|
|
});
|
|
|
|
const responseData = await response.json();
|
|
console.log("Response from server:", responseData);
|
|
|
|
if (!response.ok) {
|
|
console.error("Error response:", responseData);
|
|
throw new Error(responseData.error || "Failed to save event");
|
|
}
|
|
|
|
// Reset form and close modal first
|
|
setIsEventModalOpen(false);
|
|
setEventForm({
|
|
title: "",
|
|
description: null,
|
|
start: "",
|
|
end: "",
|
|
allDay: false,
|
|
location: null,
|
|
calendarId: selectedCalendarId
|
|
});
|
|
setSelectedEvent(null);
|
|
setError(null);
|
|
|
|
// Update calendars state with the new event
|
|
const updatedCalendars = calendars.map(cal => {
|
|
if (cal.id === eventData.calendarId) {
|
|
return {
|
|
...cal,
|
|
events: [...cal.events, responseData]
|
|
};
|
|
}
|
|
return cal;
|
|
});
|
|
setCalendars(updatedCalendars);
|
|
|
|
// Fetch fresh data to ensure all calendars are up to date
|
|
await fetchCalendars();
|
|
} catch (error) {
|
|
console.error("Error saving event:", error);
|
|
setError(error instanceof Error ? error.message : "Failed to save event");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleEventDelete = async () => {
|
|
if (!selectedEvent?.id) return;
|
|
|
|
if (!confirm("Êtes-vous sûr de vouloir supprimer cet événement ?")) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
const response = await fetch(`/api/events/${selectedEvent.id}`, {
|
|
method: "DELETE",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.message || "Failed to delete event");
|
|
}
|
|
|
|
// Remove the event from local state
|
|
const updatedCalendars = calendars.map(cal => ({
|
|
...cal,
|
|
events: cal.events.filter(e => e.id !== selectedEvent.id)
|
|
}));
|
|
setCalendars(updatedCalendars);
|
|
|
|
// Close modal and reset form
|
|
setIsEventModalOpen(false);
|
|
setSelectedEvent(null);
|
|
setEventForm({
|
|
title: "",
|
|
description: null,
|
|
start: "",
|
|
end: "",
|
|
allDay: false,
|
|
location: null,
|
|
calendarId: selectedCalendarId
|
|
});
|
|
|
|
// Force calendar refresh
|
|
if (calendarRef.current) {
|
|
const calendarApi = calendarRef.current.getApi();
|
|
calendarApi.refetchEvents();
|
|
}
|
|
} catch (error) {
|
|
console.error("Error deleting event:", error);
|
|
setError(error instanceof Error ? error.message : "Failed to delete event");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleViewChange = (newView: "dayGridMonth" | "timeGridWeek" | "timeGridDay") => {
|
|
setView(newView);
|
|
if (calendarRef.current) {
|
|
const calendarApi = calendarRef.current.getApi();
|
|
calendarApi.changeView(newView);
|
|
}
|
|
};
|
|
|
|
// Update CalendarSelector to handle visibility
|
|
const CalendarSelector = () => (
|
|
<div className="flex flex-wrap items-center gap-2 mb-4">
|
|
{calendars.map((calendar) => (
|
|
<div key={calendar.id} className="relative group">
|
|
<Button
|
|
variant={visibleCalendarIds.includes(calendar.id) ? "secondary" : "ghost"}
|
|
className="flex items-center gap-2 pr-8"
|
|
onClick={() => {
|
|
if (visibleCalendarIds.includes(calendar.id)) {
|
|
setVisibleCalendarIds(visibleCalendarIds.filter(id => id !== calendar.id));
|
|
} else {
|
|
setVisibleCalendarIds([...visibleCalendarIds, calendar.id]);
|
|
}
|
|
}}
|
|
>
|
|
<div
|
|
className="w-3 h-3 rounded-full"
|
|
style={{ backgroundColor: calendar.color }}
|
|
/>
|
|
<span>{calendar.name}</span>
|
|
<div className="ml-2">
|
|
{visibleCalendarIds.includes(calendar.id) ? (
|
|
<Check className="h-4 w-4" />
|
|
) : null}
|
|
</div>
|
|
</Button>
|
|
{calendar.name !== "Calendrier principal" && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute right-0 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSelectedCalendar(calendar);
|
|
setIsCalendarModalOpen(true);
|
|
}}
|
|
>
|
|
<Settings className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// Add these helper functions for date handling
|
|
const getDateFromString = (dateString: string) => {
|
|
return dateString ? new Date(dateString) : new Date();
|
|
};
|
|
|
|
const handleStartDateChange = (date: Date | null) => {
|
|
if (!date) return;
|
|
|
|
const endDate = getDateFromString(eventForm.end);
|
|
if (date > endDate) {
|
|
// If start date is after end date, set end date to start date + 1 hour
|
|
const newEndDate = new Date(date);
|
|
newEndDate.setHours(date.getHours() + 1);
|
|
setEventForm({
|
|
...eventForm,
|
|
start: date.toISOString(),
|
|
end: newEndDate.toISOString(),
|
|
});
|
|
} else {
|
|
setEventForm({
|
|
...eventForm,
|
|
start: date.toISOString(),
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleEndDateChange = (date: Date | null) => {
|
|
if (!date) return;
|
|
|
|
const startDate = getDateFromString(eventForm.start);
|
|
if (date < startDate) {
|
|
// If end date is before start date, set start date to end date - 1 hour
|
|
const newStartDate = new Date(date);
|
|
newStartDate.setHours(date.getHours() - 1);
|
|
setEventForm({
|
|
...eventForm,
|
|
start: newStartDate.toISOString(),
|
|
end: date.toISOString(),
|
|
});
|
|
} else {
|
|
setEventForm({
|
|
...eventForm,
|
|
end: date.toISOString(),
|
|
});
|
|
}
|
|
};
|
|
|
|
// Update the date handlers to maintain consistent time format
|
|
const formatTimeForInput = (date: Date) => {
|
|
return date.toLocaleTimeString('fr-FR', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card className="p-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-4">
|
|
<Button
|
|
onClick={() => {
|
|
setSelectedEvent(null);
|
|
setEventForm({
|
|
title: "",
|
|
description: null,
|
|
start: new Date().toISOString(),
|
|
end: new Date(new Date().setHours(new Date().getHours() + 1)).toISOString(),
|
|
allDay: false,
|
|
location: null,
|
|
calendarId: selectedCalendarId
|
|
});
|
|
setIsEventModalOpen(true);
|
|
}}
|
|
className="bg-primary hover:bg-primary/90 text-white"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
<span className="font-medium">Nouvel événement</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<Tabs value={view} className="w-auto">
|
|
<TabsList>
|
|
<TabsTrigger
|
|
value="dayGridMonth"
|
|
onClick={() => handleViewChange("dayGridMonth")}
|
|
>
|
|
Mois
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="timeGridWeek"
|
|
onClick={() => handleViewChange("timeGridWeek")}
|
|
>
|
|
Semaine
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="timeGridDay"
|
|
onClick={() => handleViewChange("timeGridDay")}
|
|
>
|
|
Jour
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
</div>
|
|
|
|
<CalendarSelector />
|
|
|
|
<style jsx global>{`
|
|
/* Fixed height and scrolling for day cells only */
|
|
.fc .fc-daygrid-day-frame {
|
|
min-height: 100px !important;
|
|
max-height: 100px !important;
|
|
overflow-y: auto !important;
|
|
}
|
|
|
|
/* Keep events container relative positioning */
|
|
.fc .fc-daygrid-day-events {
|
|
margin-bottom: 0 !important;
|
|
}
|
|
|
|
/* Custom scrollbar styles */
|
|
.fc .fc-daygrid-day-frame::-webkit-scrollbar {
|
|
width: 4px;
|
|
}
|
|
|
|
.fc .fc-daygrid-day-frame::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
.fc .fc-daygrid-day-frame::-webkit-scrollbar-thumb {
|
|
background: #888;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.fc .fc-daygrid-day-frame::-webkit-scrollbar-thumb:hover {
|
|
background: #555;
|
|
}
|
|
|
|
/* Hide scrollbar when not needed */
|
|
.fc .fc-daygrid-day-frame::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.fc .fc-daygrid-day-frame:hover::-webkit-scrollbar {
|
|
display: block;
|
|
}
|
|
`}</style>
|
|
|
|
{loading ? (
|
|
<div className="h-96 flex items-center justify-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
<span className="ml-2">Chargement des événements...</span>
|
|
</div>
|
|
) : (
|
|
<FullCalendar
|
|
ref={calendarRef}
|
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
|
initialView={view}
|
|
headerToolbar={{
|
|
left: "prev,next today",
|
|
center: "title",
|
|
right: "",
|
|
}}
|
|
events={calendars
|
|
.filter(cal => visibleCalendarIds.includes(cal.id))
|
|
.flatMap(cal =>
|
|
(cal.events || []).map(event => ({
|
|
id: event.id,
|
|
title: event.title,
|
|
start: new Date(event.start),
|
|
end: new Date(event.end),
|
|
allDay: event.isAllDay,
|
|
description: event.description,
|
|
location: event.location,
|
|
calendarId: event.calendarId,
|
|
backgroundColor: `${cal.color}dd`,
|
|
borderColor: cal.color,
|
|
textColor: '#ffffff',
|
|
extendedProps: {
|
|
calendarName: cal.name,
|
|
location: event.location,
|
|
description: event.description,
|
|
calendarId: event.calendarId,
|
|
originalEvent: event,
|
|
color: cal.color
|
|
}
|
|
}))
|
|
)}
|
|
eventContent={(arg) => {
|
|
return (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div
|
|
className="px-2 py-1 overflow-hidden w-full transition-all rounded-sm hover:brightness-110"
|
|
style={{
|
|
backgroundColor: `${arg.event.backgroundColor}`,
|
|
boxShadow: `inset 0 0 0 1px ${arg.event.borderColor}, 0 2px 4px ${arg.event.borderColor}40`
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-1.5 text-xs text-white">
|
|
{!arg.event.allDay && (
|
|
<span className="font-medium whitespace-nowrap shrink-0">
|
|
{typeof arg.event.start === 'object' && arg.event.start instanceof Date
|
|
? arg.event.start.toLocaleTimeString('fr-FR', {
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})
|
|
: ''}
|
|
</span>
|
|
)}
|
|
<span className="font-medium truncate max-w-[calc(100%-4.5rem)]">
|
|
{arg.event.title}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<div className="text-sm">
|
|
<p className="font-medium">{arg.event.title}</p>
|
|
{!arg.event.allDay && (
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
{typeof arg.event.start === 'object' && arg.event.start instanceof Date
|
|
? arg.event.start.toLocaleTimeString('fr-FR', {
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})
|
|
: ''}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
}}
|
|
eventClassNames="rounded-md overflow-hidden"
|
|
dayCellContent={(arg) => {
|
|
return (
|
|
<div className="text-xs font-medium">
|
|
{arg.dayNumberText}
|
|
</div>
|
|
);
|
|
}}
|
|
locale={frLocale}
|
|
selectable={true}
|
|
selectMirror={true}
|
|
dayMaxEventRows={false}
|
|
dayMaxEvents={false}
|
|
weekends={true}
|
|
select={handleDateSelect}
|
|
eventClick={handleEventClick}
|
|
height="auto"
|
|
aspectRatio={1.8}
|
|
slotMinTime="06:00:00"
|
|
slotMaxTime="22:00:00"
|
|
allDaySlot={true}
|
|
allDayText=""
|
|
views={{
|
|
timeGridWeek: {
|
|
allDayText: "",
|
|
dayHeaderFormat: { weekday: 'long', day: 'numeric', month: 'numeric' }
|
|
},
|
|
timeGridDay: {
|
|
allDayText: "",
|
|
dayHeaderFormat: { weekday: 'long', day: 'numeric', month: 'numeric' }
|
|
}
|
|
}}
|
|
slotLabelFormat={{
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
}}
|
|
/>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Calendar dialog */}
|
|
<CalendarDialog
|
|
open={isCalendarModalOpen}
|
|
onClose={() => setIsCalendarModalOpen(false)}
|
|
onSave={handleCalendarSave}
|
|
onDelete={handleCalendarDelete}
|
|
initialData={selectedCalendar || undefined}
|
|
/>
|
|
|
|
{/* Event dialog */}
|
|
<Dialog open={isEventModalOpen} onOpenChange={(open) => {
|
|
if (!open) {
|
|
setIsEventModalOpen(false);
|
|
setEventForm({
|
|
title: "",
|
|
description: null,
|
|
start: "",
|
|
end: "",
|
|
allDay: false,
|
|
location: null,
|
|
calendarId: selectedCalendarId || calendars[0]?.id
|
|
});
|
|
setSelectedEvent(null);
|
|
setError(null);
|
|
}
|
|
}}>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-gray-800">
|
|
{selectedEvent ? "Modifier l'événement" : "Nouvel événement"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-2 rounded-md text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-4 py-4">
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="title" className="text-base font-semibold text-gray-800">Titre</Label>
|
|
<Input
|
|
id="title"
|
|
placeholder="Titre de l'événement"
|
|
value={eventForm.title}
|
|
onChange={(e) => setEventForm({ ...eventForm, title: e.target.value })}
|
|
className="bg-white text-gray-900"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-base font-semibold text-gray-800">Calendrier</Label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{calendars.map((cal) => (
|
|
<button
|
|
key={cal.id}
|
|
type="button"
|
|
onClick={() => {
|
|
setEventForm(prev => ({
|
|
...prev,
|
|
calendarId: cal.id
|
|
}));
|
|
}}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
|
eventForm.calendarId === cal.id
|
|
? 'bg-white ring-2 ring-primary'
|
|
: 'bg-white hover:bg-gray-50 border border-gray-200'
|
|
}`}
|
|
>
|
|
<div
|
|
className="w-3 h-3 rounded-full"
|
|
style={{ backgroundColor: cal.color }}
|
|
/>
|
|
<span className={`text-sm ${
|
|
eventForm.calendarId === cal.id
|
|
? 'font-medium text-gray-900'
|
|
: 'text-gray-700'
|
|
}`}>
|
|
{cal.name}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label className="text-gray-800">Début</Label>
|
|
<div className="flex gap-2">
|
|
<div className="flex-1">
|
|
<DatePicker
|
|
selected={getDateFromString(eventForm.start)}
|
|
onChange={handleStartDateChange}
|
|
dateFormat="dd/MM/yyyy"
|
|
locale="fr"
|
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-white text-gray-900"
|
|
placeholderText="Date"
|
|
customInput={<Input className="bg-white text-gray-900" />}
|
|
/>
|
|
</div>
|
|
<DatePicker
|
|
selected={getDateFromString(eventForm.start)}
|
|
onChange={handleStartDateChange}
|
|
showTimeSelect
|
|
showTimeSelectOnly
|
|
timeIntervals={15}
|
|
timeCaption="Heure"
|
|
dateFormat="HH:mm"
|
|
className="w-32 bg-white text-gray-900"
|
|
customInput={<Input className="bg-white text-gray-900" />}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-gray-800">Fin</Label>
|
|
<div className="flex gap-2">
|
|
<div className="flex-1">
|
|
<DatePicker
|
|
selected={getDateFromString(eventForm.end)}
|
|
onChange={handleEndDateChange}
|
|
dateFormat="dd/MM/yyyy"
|
|
locale="fr"
|
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-white text-gray-900"
|
|
placeholderText="Date"
|
|
customInput={<Input className="bg-white text-gray-900" />}
|
|
minDate={getDateFromString(eventForm.start)}
|
|
/>
|
|
</div>
|
|
<DatePicker
|
|
selected={getDateFromString(eventForm.end)}
|
|
onChange={handleEndDateChange}
|
|
showTimeSelect
|
|
showTimeSelectOnly
|
|
timeIntervals={15}
|
|
timeCaption="Heure"
|
|
dateFormat="HH:mm"
|
|
className="w-32 bg-white text-gray-900"
|
|
customInput={<Input className="bg-white text-gray-900" />}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="allDay"
|
|
checked={eventForm.allDay}
|
|
onCheckedChange={(checked) =>
|
|
setEventForm({ ...eventForm, allDay: checked as boolean })
|
|
}
|
|
/>
|
|
<Label htmlFor="allDay" className="text-gray-800">Toute la journée</Label>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-gray-800">Lieu</Label>
|
|
<Input
|
|
value={eventForm.location || ""}
|
|
onChange={(e) =>
|
|
setEventForm({ ...eventForm, location: e.target.value })
|
|
}
|
|
placeholder="Ajouter un lieu"
|
|
className="bg-white text-gray-900"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-gray-800">Description</Label>
|
|
<Textarea
|
|
value={eventForm.description || ""}
|
|
onChange={(e) =>
|
|
setEventForm({ ...eventForm, description: e.target.value })
|
|
}
|
|
placeholder="Ajouter une description"
|
|
className="bg-white text-gray-900"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
{selectedEvent && (
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleEventDelete}
|
|
disabled={loading}
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Suppression...
|
|
</>
|
|
) : (
|
|
"Supprimer"
|
|
)}
|
|
</Button>
|
|
)}
|
|
<div className="flex space-x-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsEventModalOpen(false)}
|
|
disabled={loading}
|
|
className="bg-white hover:bg-gray-50 text-gray-900 border-gray-200"
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
onClick={handleEventSubmit}
|
|
disabled={loading}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Enregistrement...
|
|
</>
|
|
) : selectedEvent ? (
|
|
"Mettre à jour"
|
|
) : (
|
|
"Créer"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|