NeahStable/components/calendar/calendar-client.tsx
2026-01-15 18:25:09 +01:00

2081 lines
76 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,
RefreshCw,
Link as LinkIcon
} 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 CalendarWithMission extends Calendar {
missionId?: string | null;
mission?: {
id: string;
creatorId: string;
missionUsers: Array<{
userId: string;
role: string;
}>;
} | null;
syncConfig?: {
id: string;
provider: string;
externalCalendarId: string | null;
externalCalendarUrl: string | null;
syncEnabled: boolean;
lastSyncAt: Date | null;
syncFrequency: number;
lastSyncError: string | null;
mailCredential?: {
id: string;
email: string;
display_name: string | null;
} | null;
} | null;
}
interface CalendarClientProps {
initialCalendars: (CalendarWithMission & { 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>;
onSyncSetup?: (calendarId: string, mailCredentialId: string, externalCalendarUrl: string, externalCalendarId?: string, provider?: string) => Promise<void>;
initialData?: Partial<Calendar>;
syncConfig?: {
id: string;
provider: string;
syncEnabled: boolean;
lastSyncAt: Date | null;
mailCredential?: {
id: string;
email: string;
display_name: string | null;
} | null;
} | null;
}
function CalendarDialog({ open, onClose, onSave, onDelete, onSyncSetup, initialData, syncConfig }: 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);
// Sync state
const [showSyncSection, setShowSyncSection] = useState(false);
const [availableAccounts, setAvailableAccounts] = useState<Array<{ id: string; email: string; display_name: string | null }>>([]);
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
const [availableCalendars, setAvailableCalendars] = useState<Array<{ id: string; name: string; url: string }>>([]);
const [selectedCalendarUrl, setSelectedCalendarUrl] = useState<string>("");
const [isDiscovering, setIsDiscovering] = useState(false);
const [isSettingUpSync, setIsSettingUpSync] = useState(false);
const isMainCalendar = initialData?.name === "Calendrier principal";
const isMissionOrGroupCalendar = initialData?.name?.startsWith("Mission:") || initialData?.name?.startsWith("Groupe:");
const isPrivateCalendar = !isMissionOrGroupCalendar && (initialData?.name === "Privée" || initialData?.name === "Default" || !initialData?.name);
useEffect(() => {
if (open) {
setName(initialData?.name || "");
setColor(initialData?.color || "#4f46e5");
setDescription(initialData?.description || "");
setCustomColorMode(!colorPalette.includes(initialData?.color || "#4f46e5"));
setShowSyncSection(false);
setSelectedAccountId("");
setAvailableCalendars([]);
setSelectedCalendarUrl("");
// Load available accounts for sync
if (isPrivateCalendar && !syncConfig) {
loadAvailableAccounts();
}
}
}, [open, initialData, syncConfig, isPrivateCalendar]);
const loadAvailableAccounts = async () => {
try {
const response = await fetch("/api/courrier/account-list");
if (response.ok) {
const data = await response.json();
if (data.success && data.accounts) {
// Filter Microsoft accounts only
const syncableAccounts = data.accounts.filter((acc: any) =>
(acc.host && acc.host.includes('outlook.office365.com') && acc.use_oauth)
);
setAvailableAccounts(syncableAccounts.map((acc: any) => ({
id: acc.id,
email: acc.email,
display_name: acc.display_name,
host: acc.host
})));
}
}
} catch (error) {
console.error("Error loading accounts:", error);
}
};
const handleDiscoverCalendars = async () => {
if (!selectedAccountId) return;
setIsDiscovering(true);
try {
// Use Microsoft endpoint only
const endpoint = "/api/calendars/sync/discover-microsoft";
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mailCredentialId: selectedAccountId }),
});
if (response.ok) {
const data = await response.json();
// Normalize calendar format (Microsoft uses 'id' and 'webLink')
const normalizedCalendars = (data.calendars || []).map((cal: any) => ({
id: cal.id,
name: cal.name,
url: cal.webLink || cal.id, // Use webLink or id as fallback
}));
setAvailableCalendars(normalizedCalendars);
} else {
const error = await response.json();
alert(error.error || "Erreur lors de la découverte des calendriers");
}
} catch (error) {
console.error("Error discovering calendars:", error);
alert("Erreur lors de la découverte des calendriers");
} finally {
setIsDiscovering(false);
}
};
const handleSetupSync = async () => {
if (!initialData?.id || !selectedAccountId || !selectedCalendarUrl || !onSyncSetup) return;
setIsSettingUpSync(true);
try {
// All accounts are Microsoft
const selectedAccount = availableAccounts.find(acc => acc.id === selectedAccountId);
const provider = 'microsoft';
// For Microsoft, use calendar ID instead of URL
const externalCalendarId = availableCalendars.find(cal => cal.url === selectedCalendarUrl)?.id || selectedCalendarUrl;
await onSyncSetup(initialData.id, selectedAccountId, selectedCalendarUrl, externalCalendarId, provider);
setShowSyncSection(false);
alert("Synchronisation configurée avec succès !");
} catch (error) {
console.error("Error setting up sync:", error);
alert("Erreur lors de la configuration de la synchronisation");
} finally {
setIsSettingUpSync(false);
}
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
try {
// Only update color, preserve name and description from initialData
await onSave({
id: initialData?.id,
name: initialData?.name || name, // Keep original name
color, // Only color can be changed
description: initialData?.description || description // Keep original description
});
resetForm();
} catch (error) {
console.error("Erreur lors de la mise à jour 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 ? "Paramètres du calendrier" : "Créer un nouveau calendrier"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-5 py-4">
{/* Display calendar name (read-only) */}
{initialData?.id && (
<div className="space-y-2">
<Label className="text-gray-700">Nom</Label>
<div className="px-3 py-2 bg-gray-50 rounded-lg text-gray-900 border border-gray-200">
{initialData?.name || name}
</div>
</div>
)}
{/* Name input only for new calendars */}
{!initialData?.id && (
<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
className="rounded-lg border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 bg-white text-gray-900"
/>
</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>
{/* Display description (read-only) for existing calendars */}
{initialData?.id && initialData?.description && (
<div className="space-y-2">
<Label className="text-gray-700">Description</Label>
<div className="px-3 py-2 bg-gray-50 rounded-lg text-gray-700 border border-gray-200 text-sm">
{initialData?.description || description}
</div>
</div>
)}
{/* Description input only for new calendars */}
{!initialData?.id && (
<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 bg-white text-gray-900"
/>
</div>
)}
{/* Sync Section for Private Calendars */}
{isPrivateCalendar && initialData?.id && (
<div className="space-y-3 pt-4 border-t border-gray-200">
{syncConfig?.syncEnabled ? (
<div className="p-3 bg-blue-50 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-blue-900">Synchronisation active</p>
<p className="text-xs text-blue-700 mt-1">
{syncConfig.mailCredential?.display_name || syncConfig.mailCredential?.email || "Compte"}
</p>
{syncConfig.lastSyncAt && (
<p className="text-xs text-blue-600 mt-1">
Dernière sync: {new Date(syncConfig.lastSyncAt).toLocaleString('fr-FR')}
</p>
)}
</div>
<Badge variant="outline" className="border-blue-400 text-blue-600">
<RefreshCw className="w-3 h-3 mr-1" />
Sync
</Badge>
</div>
</div>
) : (
<>
<div className="flex items-center justify-between">
<Label className="text-gray-700">Synchronisation avec courrier</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowSyncSection(!showSyncSection)}
className="text-xs"
>
<LinkIcon className="w-3 h-3 mr-1" />
{showSyncSection ? "Masquer" : "Configurer"}
</Button>
</div>
{showSyncSection && (
<div className="space-y-3 p-3 bg-gray-50 rounded-lg">
<div className="space-y-2">
<Label className="text-sm text-gray-700">Compte email (Microsoft)</Label>
<select
value={selectedAccountId}
onChange={(e) => {
setSelectedAccountId(e.target.value);
setAvailableCalendars([]);
setSelectedCalendarUrl("");
}}
className="w-full rounded-lg border-gray-300 bg-white text-gray-900 text-sm p-2"
>
<option value="">Sélectionner un compte</option>
{availableAccounts.map((account) => (
<option key={account.id} value={account.id}>
{account.display_name || account.email}
</option>
))}
</select>
</div>
{selectedAccountId && (
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDiscoverCalendars}
disabled={isDiscovering}
className="w-full"
>
{isDiscovering ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Découverte en cours...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Découvrir les calendriers
</>
)}
</Button>
{availableCalendars.length > 0 && (
<div className="space-y-2">
<Label className="text-sm text-gray-700">Calendrier à synchroniser</Label>
<select
value={selectedCalendarUrl}
onChange={(e) => setSelectedCalendarUrl(e.target.value)}
className="w-full rounded-lg border-gray-300 bg-white text-gray-900 text-sm p-2"
>
<option value="">Sélectionner un calendrier</option>
{availableCalendars.map((cal) => (
<option key={cal.id} value={cal.url}>
{cal.name}
</option>
))}
</select>
</div>
)}
{selectedCalendarUrl && (
<Button
type="button"
onClick={handleSetupSync}
disabled={isSettingUpSync}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
{isSettingUpSync ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Configuration...
</>
) : (
<>
<LinkIcon className="w-4 h-4 mr-2" />
Activer la synchronisation
</>
)}
</Button>
)}
</>
)}
</div>
)}
</>
)}
</div>
)}
</div>
<DialogFooter className="mt-6 border-t border-gray-100 pt-4">
<div className="flex justify-end w-full">
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isSubmitting}
className="rounded-lg border-gray-300 bg-gray-200 text-gray-700 hover:bg-gray-300"
>
Annuler
</Button>
<Button
type="submit"
disabled={isSubmitting || (initialData?.id ? false : !name)}
className="rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white"
>
{isSubmitting
? "Enregistrement..."
: initialData?.id
? "Mettre à jour la couleur"
: "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">
{cleanDescription(event.description) && (
<p className="text-sm text-gray-600">{cleanDescription(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) {
// Filter out "Privée" and "Default" calendars that are not synced
const filterCalendars = (cals: typeof initialCalendars) => {
return cals.filter(cal => {
// Keep calendars that are synced, groups, missions, or have a different name
const isSynced = cal.syncConfig?.syncEnabled && cal.syncConfig?.mailCredential;
const isGroup = cal.name?.startsWith("Groupe:");
const isMission = cal.name?.startsWith("Mission:");
const isPrivateOrDefault = cal.name === "Privée" || cal.name === "Default";
const isPrivateNotSynced = isPrivateOrDefault && !isSynced;
// Exclude "Privée" and "Default" calendars that are not synced
return !isPrivateNotSynced;
});
};
// Sort calendars: "Mon Calendrier" first, then synced (courrier), then groups, then missions
const sortCalendars = (cals: typeof initialCalendars) => {
const filtered = filterCalendars(cals);
return [...filtered].sort((a, b) => {
const aIsMonCalendrier = a.name === "Mon Calendrier";
const bIsMonCalendrier = b.name === "Mon Calendrier";
const aIsSynced = a.syncConfig?.syncEnabled && a.syncConfig?.mailCredential;
const bIsSynced = b.syncConfig?.syncEnabled && b.syncConfig?.mailCredential;
const aIsGroup = a.name?.startsWith("Groupe:");
const bIsGroup = b.name?.startsWith("Groupe:");
const aIsMission = a.name?.startsWith("Mission:");
const bIsMission = b.name?.startsWith("Mission:");
// "Mon Calendrier" always first
if (aIsMonCalendrier && !bIsMonCalendrier) return -1;
if (!aIsMonCalendrier && bIsMonCalendrier) return 1;
// Synced calendars second
if (aIsSynced && !bIsSynced) return -1;
if (!aIsSynced && bIsSynced) return 1;
// If both synced or both not synced, check groups
if (aIsGroup && !bIsGroup && !bIsSynced) return -1;
if (!aIsGroup && bIsGroup && !aIsSynced) return 1;
// If both groups or both not groups, check missions
if (aIsMission && !bIsMission && !bIsGroup && !bIsSynced) return -1;
if (!aIsMission && bIsMission && !aIsGroup && !aIsSynced) return 1;
// Same type, sort by name
return (a.name || '').localeCompare(b.name || '');
});
};
const [calendars, setCalendars] = useState(sortCalendars(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[]>([]);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const clickTimerRef = useRef<NodeJS.Timeout | null>(null);
const lastClickDateRef = useRef<Date | null>(null);
// Update useEffect to initialize visible calendars and fetch events
useEffect(() => {
if (calendars.length > 0) {
setVisibleCalendarIds(calendars.map(cal => cal.id));
updateStatistics();
updateUpcomingEvents();
}
}, [calendars]);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
}
};
}, []);
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 (forceRefresh: boolean = false) => {
try {
setLoading(true);
const url = forceRefresh ? "/api/calendars?refresh=true" : "/api/calendars";
const response = await fetch(url);
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(sortCalendars(processedCalendars as typeof initialCalendars).map(cal => ({
...cal,
events: cal.events || []
})));
// 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 handleDateClick = (clickInfo: any) => {
const clickedDate = new Date(clickInfo.date);
clickedDate.setHours(0, 0, 0, 0);
// Check if this is a double click (same date clicked within 300ms)
const now = new Date();
const isDoubleClick = lastClickDateRef.current &&
lastClickDateRef.current.getTime() === clickedDate.getTime() &&
now.getTime() - (lastClickDateRef.current as any).clickTime < 300;
// Clear any pending single click timer
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
}
if (isDoubleClick) {
// Double click: Open event form
const startDate = new Date(clickInfo.date);
startDate.setHours(new Date().getHours(), 0, 0, 0);
const endDate = new Date(startDate);
endDate.setHours(startDate.getHours() + 1);
// If no calendar is selected, use the first available calendar
if (!selectedCalendarId && calendars.length > 0) {
const firstCalendar = calendars[0];
setSelectedCalendarId(firstCalendar.id);
setEventForm({
title: "",
description: null,
start: startDate.toISOString(),
end: endDate.toISOString(),
allDay: false,
location: null,
calendarId: firstCalendar.id
});
} else {
setEventForm({
title: "",
description: null,
start: startDate.toISOString(),
end: endDate.toISOString(),
allDay: false,
location: null,
calendarId: selectedCalendarId
});
}
setIsEventModalOpen(true);
lastClickDateRef.current = null;
} else {
// Single click: Show events column (with a small delay to allow double click detection)
lastClickDateRef.current = clickedDate;
(lastClickDateRef.current as any).clickTime = now.getTime();
clickTimerRef.current = setTimeout(() => {
setSelectedDate(clickedDate);
clickTimerRef.current = null;
}, 300);
}
};
const handleDateSelect = (selectInfo: any) => {
// Double click or drag selection: Open event form
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
});
// Also set selected date to show events for the selected day
const selectDate = new Date(startDate);
selectDate.setHours(0, 0, 0, 0);
setSelectedDate(selectDate);
// 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: cleanDescription(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);
// Invalidate cache and fetch fresh data to ensure all calendars are up to date
// Force refresh by adding ?refresh=true to bypass cache
await fetchCalendars(true);
} 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(sortCalendars(updatedCalendars as typeof initialCalendars).map(cal => ({
...cal,
events: cal.events || []
})));
// 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);
}
};
// Helper function to clean description by removing Microsoft ID prefix
const cleanDescription = (description: string | null | undefined): string | null => {
if (!description) return null;
// Remove [MS_ID:xxx] prefix if present
const cleaned = description.replace(/^\[MS_ID:[^\]]+\]\n?/, '');
return cleaned.trim() || null;
};
const getCalendarDisplayName = (calendar: CalendarWithMission) => {
// If calendar is synced to an external account, use the same display name as in courrier
if (calendar.syncConfig?.syncEnabled && calendar.syncConfig?.mailCredential) {
// Use display_name if available, otherwise use email (same logic as courrier page)
return calendar.syncConfig.mailCredential.display_name ||
calendar.syncConfig.mailCredential.email;
}
// For non-synced calendars, use the calendar name
// Don't show "Privée" for non-synced calendars - they should be filtered out
return calendar.name;
};
// Update CalendarSelector to handle visibility - displayed as a left column
const CalendarSelector = () => (
<div className="flex flex-col 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 w-full justify-start"
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 flex-shrink-0"
style={{ backgroundColor: calendar.color }}
/>
<span className="flex items-center gap-1 min-w-0 flex-1">
<span className="truncate">
{getCalendarDisplayName(calendar as CalendarWithMission)}
</span>
{calendar.syncConfig?.syncEnabled && (
<Badge variant="outline" className="text-[10px] px-1 py-0.5 border-blue-400 text-blue-600 flex-shrink-0">
Sync
</Badge>
)}
</span>
<div className="ml-auto">
{visibleCalendarIds.includes(calendar.id) ? (
<Check className="h-4 w-4" />
) : null}
</div>
</Button>
{calendar.name !== "Calendrier principal" &&
calendar.name !== "Default" &&
!calendar.name.startsWith("Mission:") &&
!calendar.name.startsWith("Groupe:") && (
<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
});
};
// Get events for the selected date
const getDayEvents = () => {
if (!selectedDate) return [];
const dayStart = new Date(selectedDate);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(selectedDate);
dayEnd.setHours(23, 59, 59, 999);
const visibleCalendars = calendars.filter(cal => visibleCalendarIds.includes(cal.id));
const dayEvents = visibleCalendars.flatMap(cal => {
return (cal.events || []).filter(event => {
const eventStart = new Date(event.start);
const eventEnd = new Date(event.end);
// Check if event overlaps with the selected day
return (eventStart <= dayEnd && eventEnd >= dayStart);
}).map(event => ({
...event,
calendar: cal
}));
});
// Sort by start time
return dayEvents.sort((a, b) => {
const aStart = new Date(a.start).getTime();
const bStart = new Date(b.start).getTime();
return aStart - bStart;
});
};
const dayEvents = getDayEvents();
return (
<div className="flex gap-4 h-full w-full">
{/* Left column for calendars */}
<div className="w-64 flex-shrink-0">
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Calendriers</h3>
</div>
<CalendarSelector />
</Card>
</div>
{/* Middle column for calendar view */}
<div className="flex-1 flex flex-col">
<Card className="p-4 flex-1 flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
{/* Display selected calendar name */}
{(() => {
const selectedCal = calendars.find(
cal => visibleCalendarIds.includes(cal.id) && visibleCalendarIds.length === 1
) as CalendarWithMission | undefined;
const displayName = selectedCal
? getCalendarDisplayName(selectedCal)
: "Tous les calendriers";
return (
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-gray-900">
{displayName}
</h2>
{selectedCal?.syncConfig?.syncEnabled && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5 border-blue-400 text-blue-600">
Synchronisé
</Badge>
)}
</div>
);
})()}
{(() => {
// Check if user can create events in the selected calendar(s)
const canCreateEvent = () => {
// If multiple calendars are visible, allow creation
if (visibleCalendarIds.length !== 1) {
return true;
}
const selectedCal = calendars.find(cal => cal.id === visibleCalendarIds[0]);
if (!selectedCal) return true;
// If calendar is not linked to a mission, allow
if (!selectedCal.missionId || !selectedCal.mission) {
return true;
}
const mission = selectedCal.mission;
// Check if user is the creator
if (mission.creatorId === userId) {
return true;
}
// Check if user is gardien-temps
const isGardienTemps = mission.missionUsers.some(
(mu) => mu.userId === userId && mu.role === 'gardien-temps'
);
return isGardienTemps;
};
if (!canCreateEvent()) {
return null;
}
return (
<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-blue-600 hover:bg-blue-700 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 className="bg-gray-100">
<TabsTrigger
value="dayGridMonth"
onClick={() => handleViewChange("dayGridMonth")}
className="data-[state=active]:bg-gray-300 data-[state=active]:text-gray-900 text-gray-700 hover:text-gray-900"
>
Mois
</TabsTrigger>
<TabsTrigger
value="timeGridWeek"
onClick={() => handleViewChange("timeGridWeek")}
className="data-[state=active]:bg-gray-300 data-[state=active]:text-gray-900 text-gray-700 hover:text-gray-900"
>
Semaine
</TabsTrigger>
<TabsTrigger
value="timeGridDay"
onClick={() => handleViewChange("timeGridDay")}
className="data-[state=active]:bg-gray-300 data-[state=active]:text-gray-900 text-gray-700 hover:text-gray-900"
>
Jour
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex-1 overflow-auto">
<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: "",
}}
dateClick={handleDateClick}
events={(() => {
const visibleCalendars = calendars.filter(cal => visibleCalendarIds.includes(cal.id));
const allEvents = visibleCalendars.flatMap(cal => {
const events = cal.events || [];
console.log(`[CALENDAR] Calendar ${cal.name} (${cal.id}): ${events.length} events, visible: ${visibleCalendarIds.includes(cal.id)}`);
if (events.length > 0) {
console.log(`[CALENDAR] Events for ${cal.name}:`, events.slice(0, 3).map(e => ({
id: e.id,
title: e.title,
start: e.start,
isAllDay: e.isAllDay
})));
}
return events.map(event => ({
id: event.id,
title: event.title,
start: new Date(event.start),
end: new Date(event.end),
allDay: event.isAllDay,
description: cleanDescription(event.description),
location: event.location,
calendarId: event.calendarId,
backgroundColor: `${cal.color}dd`,
borderColor: cal.color,
textColor: '#ffffff',
extendedProps: {
calendarName: getCalendarDisplayName(cal as CalendarWithMission),
location: event.location,
description: cleanDescription(event.description),
calendarId: event.calendarId,
originalEvent: event,
color: cal.color
}
}));
});
console.log(`[CALENDAR] Total events for FullCalendar: ${allEvents.length}`);
return allEvents;
})()}
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={false}
dayMaxEventRows={false}
dayMaxEvents={false}
weekends={true}
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
}}
/>
)}
</div>
</Card>
</div>
{/* Right column for day events */}
{selectedDate && (
<div className="w-80 flex-shrink-0">
<Card className="p-4 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">
{selectedDate.toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</h3>
<Button
variant="ghost"
size="icon"
onClick={() => setSelectedDate(null)}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
<ScrollArea className="flex-1">
{dayEvents.length === 0 ? (
<div className="text-center text-gray-500 py-8">
<CalendarIcon className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>Aucun événement ce jour</p>
</div>
) : (
<div className="space-y-3">
{dayEvents.map((event) => {
const calendar = event.calendar as CalendarWithMission;
const startDate = new Date(event.start);
const endDate = new Date(event.end);
const isAllDay = event.isAllDay;
return (
<Card
key={event.id}
className="p-3 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => handleEventClick({ event: {
title: event.title,
start: startDate,
end: endDate,
isAllDay: isAllDay,
extendedProps: {
description: event.description,
location: event.location,
calendarId: event.calendarId,
originalEvent: event,
color: calendar.color
}
}})}
>
<div className="flex items-start gap-3">
<div
className="w-1 h-full rounded-full flex-shrink-0 mt-1"
style={{ backgroundColor: calendar.color }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-gray-900 truncate">
{event.title}
</h4>
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: calendar.color }}
/>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Clock className="h-3 w-3" />
<span>
{isAllDay
? "Toute la journée"
: `${startDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} - ${endDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`
}
</span>
</div>
{event.location && (
<div className="flex items-center gap-2 text-sm text-gray-600 mt-1">
<MapPin className="h-3 w-3" />
<span className="truncate">{event.location}</span>
</div>
)}
<div className="mt-1">
<Badge
variant="outline"
className="text-xs"
style={{
borderColor: calendar.color,
color: calendar.color
}}
>
{getCalendarDisplayName(calendar)}
</Badge>
</div>
</div>
</div>
</Card>
);
})}
</div>
)}
</ScrollArea>
</Card>
</div>
)}
{/* Calendar dialog */}
<CalendarDialog
open={isCalendarModalOpen}
onClose={() => setIsCalendarModalOpen(false)}
onSave={handleCalendarSave}
onDelete={handleCalendarDelete}
onSyncSetup={async (calendarId, mailCredentialId, externalCalendarUrl, externalCalendarId, provider) => {
try {
const response = await fetch("/api/calendars/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
calendarId,
mailCredentialId,
externalCalendarUrl,
externalCalendarId,
provider: provider || "microsoft",
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Erreur lors de la configuration");
}
await fetchCalendars();
} catch (error) {
console.error("Error setting up sync:", error);
throw error;
}
}}
initialData={selectedCalendar || undefined}
syncConfig={selectedCalendar ? (calendars.find(c => c.id === selectedCalendar.id) as CalendarWithMission)?.syncConfig || null : null}
/>
{/* 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) => {
const calWithMission = cal as CalendarWithMission;
const label = getCalendarDisplayName(calWithMission);
return (
<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'
}`}>
{label}
</span>
{calWithMission.syncConfig?.syncEnabled && (
<Badge variant="outline" className="ml-auto text-[10px] px-1 py-0.5 border-blue-400 text-blue-600">
Sync
</Badge>
)}
</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>
);
}