"use client"; import { useState, useEffect, useCallback } from "react"; import { useSession } from "next-auth/react"; import { redirect } from "next/navigation"; import { ResponsiveIframe } from "@/app/components/responsive-iframe"; import { Users, FolderKanban, Video, ArrowLeft, Loader2, Calendar, Clock, Plus, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useToast } from "@/components/ui/use-toast"; import { Calendar as CalendarComponent } from "@/components/ui/calendar"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Textarea } from "@/components/ui/textarea"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import DatePicker, { registerLocale } from "react-datepicker"; import "react-datepicker/dist/react-datepicker.css"; import { fr } from "date-fns/locale"; registerLocale('fr', fr); interface Group { id: string; name: string; path: string; calendarColor?: string | null; } interface Mission { id: string; name: string; logoUrl?: string | null; creator?: { id: string; }; creatorId?: string; missionUsers?: Array<{ userId: string; role: string; user?: { id: string; }; }>; } interface ScheduledMeeting { id: string; type: "group" | "mission"; entityId: string; entityName: string; date: string; // ISO date string time: string; // HH:mm format title?: string; start: string; // ISO datetime string for start end: string; // ISO datetime string for end isAllDay?: boolean; location?: string; // Jitsi URL for Vision meetings } type ConferenceType = "group" | "mission" | null; export default function VisionPage() { const { data: session, status } = useSession(); const { toast } = useToast(); const [groups, setGroups] = useState([]); const [missions, setMissions] = useState([]); const [loading, setLoading] = useState(true); const [selectedConference, setSelectedConference] = useState<{ type: ConferenceType; id: string; name: string; } | null>(null); const [jitsiUrl, setJitsiUrl] = useState(null); const [scheduledMeetings, setScheduledMeetings] = useState([]); const [meetingsLoading, setMeetingsLoading] = useState(false); const [showMeetingDialog, setShowMeetingDialog] = useState(false); const [selectedDate, setSelectedDate] = useState(new Date()); const [meetingForm, setMeetingForm] = useState<{ type: "group" | "mission" | ""; entityId: string; entityName: string; title: string; start: string; end: string; allDay: boolean; description: string; recurrence: "none" | "daily" | "weekly" | "monthly"; }>({ type: "", entityId: "", entityName: "", title: "", start: "", end: "", allDay: false, description: "", recurrence: "none", }); // Redirect if not authenticated useEffect(() => { if (status === "unauthenticated") { redirect("/signin"); } }, [status]); // Helper function to convert calendars to ScheduledMeeting format const convertCalendarsToMeetings = (calendarsData: any[]): ScheduledMeeting[] => { const groupAndMissionCalendars = calendarsData.filter((cal: any) => { const isGroup = cal.name?.startsWith("Groupe:"); const isMission = cal.name?.startsWith("Mission:"); return isGroup || isMission; }); const meetings: ScheduledMeeting[] = []; groupAndMissionCalendars.forEach((calendar: any) => { const calendarName = calendar.name || ""; const isGroup = calendarName.startsWith("Groupe:"); const isMission = calendarName.startsWith("Mission:"); // Extract entity name from calendar name let entityName = ""; let entityId = ""; if (isGroup) { entityName = calendarName.replace("Groupe: ", ""); // Find matching group by name const matchingGroup = groups.find((g: Group) => g.name === entityName); entityId = matchingGroup?.id || ""; } else if (isMission) { entityName = calendarName.replace("Mission: ", ""); // Use calendar.missionId first (most reliable), then try to find by name if (calendar.missionId) { entityId = calendar.missionId; // Verify the mission exists in our list const matchingMission = missions.find((m: Mission) => m.id === calendar.missionId); if (!matchingMission) { // If mission not found in our list, still use the missionId from calendar // This can happen if missions haven't loaded yet console.warn('[Vision] Mission from calendar.missionId not found in missions list:', { calendarMissionId: calendar.missionId, availableMissionIds: missions.map((m: Mission) => m.id) }); } } else { // Fallback: find matching mission by name const matchingMission = missions.find((m: Mission) => m.name === entityName); entityId = matchingMission?.id || ""; // Debug log for mission calendar matching if (!entityId) { console.warn('[Vision] Mission entityId not found:', { calendarName, entityName, missionIds: missions.map((m: Mission) => m.id), missionNames: missions.map((m: Mission) => m.name), calendarMissionId: calendar.missionId }); } } } // Convert events to ScheduledMeeting format (calendar.events || []).forEach((event: any) => { const eventStart = new Date(event.start); const eventEnd = new Date(event.end); // Use local date to avoid timezone issues const year = eventStart.getFullYear(); const month = String(eventStart.getMonth() + 1).padStart(2, '0'); const day = String(eventStart.getDate()).padStart(2, '0'); const dateStr = `${year}-${month}-${day}`; const timeStr = event.isAllDay ? "" : eventStart.toTimeString().slice(0, 5); meetings.push({ id: event.id, type: isGroup ? "group" : "mission", entityId: entityId, entityName: entityName, date: dateStr, time: timeStr, title: event.title, start: event.start, end: event.end, isAllDay: event.isAllDay || false, location: event.location || undefined, // Include Jitsi URL if available }); }); }); return meetings; }; // Function to fetch and refresh meetings from database const fetchMeetings = useCallback(async (showLoading = true) => { if (status !== "authenticated" || !session?.user?.id) { return; } try { if (showLoading) { setMeetingsLoading(true); } // Fetch calendars with events, use refresh=true to bypass cache const calendarsResponse = await fetch("/api/calendars?refresh=true"); if (!calendarsResponse.ok) { throw new Error("Impossible de charger les calendriers"); } const calendarsData = await calendarsResponse.json(); const meetings = convertCalendarsToMeetings(calendarsData); // Debug log console.log('[Vision] Loaded meetings:', { totalMeetings: meetings.length, todayMeetings: meetings.filter(m => m.date === formatDateLocal(new Date())).length, meetings: meetings.map(m => ({ id: m.id, title: m.title, date: m.date, time: m.time, type: m.type, entityId: m.entityId, entityName: m.entityName, start: m.start, end: m.end, location: m.location })) }); setScheduledMeetings(meetings); } catch (error) { console.error("Error loading meetings:", error); toast({ title: "Erreur", description: "Impossible de charger les réunions", variant: "destructive", }); } finally { if (showLoading) { setMeetingsLoading(false); } } }, [session, status, groups, missions, toast]); // Load meetings from database (events from group and mission calendars) useEffect(() => { fetchMeetings(); // Refresh meetings every minute to update "Rejoindre" button status const interval = setInterval(() => { fetchMeetings(false); // Don't show loading spinner on auto-refresh }, 60000); // Every minute return () => clearInterval(interval); }, [fetchMeetings]); // Fetch user groups and missions useEffect(() => { const fetchData = async () => { if (status !== "authenticated" || !session?.user?.id) { return; } try { setLoading(true); const userId = session.user.id; // Fetch user groups const groupsRes = await fetch(`/api/users/${userId}/groups`); if (groupsRes.ok) { const groupsData = await groupsRes.json(); console.log('[Vision] Groups loaded:', groupsData.map((g: any) => ({ id: g.id, name: g.name, calendarColor: g.calendarColor }))); setGroups(Array.isArray(groupsData) ? groupsData : []); } else { console.error("Failed to fetch groups"); } // Fetch all missions and filter for user's missions const missionsRes = await fetch("/api/missions?limit=1000"); if (missionsRes.ok) { const missionsData = await missionsRes.json(); const allMissions = missionsData.missions || []; // Filter missions where user is creator or member const userMissions = allMissions.filter((mission: any) => { const isCreator = mission.creator?.id === userId; const isMember = mission.missionUsers?.some( (mu: any) => mu.user?.id === userId ); return isCreator || isMember; }); setMissions(userMissions); } else { console.error("Failed to fetch missions"); } } catch (error) { console.error("Error fetching data:", error); toast({ title: "Erreur", description: "Impossible de charger les données", variant: "destructive", }); } finally { setLoading(false); } }; fetchData(); }, [session, status, toast]); // Generate Jitsi URL (shared function used by both handleConferenceClick and form) const generateJitsiUrl = (type: "group" | "mission", id: string): string => { const baseUrl = process.env.NEXT_PUBLIC_IFRAME_CONFERENCE_URL || 'https://vision.slm-lab.net'; if (type === "group") { // URL format: https://vision.slm-lab.net/Groupe-{groupId} return `${baseUrl}/Groupe-${id}`; } else { // URL format: https://vision.slm-lab.net/{missionId} return `${baseUrl}/${id}`; } }; // Handle conference selection const handleConferenceClick = (type: ConferenceType, id: string, name: string, jitsiUrl?: string) => { // If Jitsi URL is provided (from meeting location), use it directly if (jitsiUrl) { setSelectedConference({ type, id, name }); setJitsiUrl(jitsiUrl); return; } // Otherwise, generate URL from type and id using shared function if (!type) return; // Type must be "group" or "mission" const url = generateJitsiUrl(type, id); setSelectedConference({ type, id, name }); setJitsiUrl(url); }; // Handle back to list const handleBack = () => { setSelectedConference(null); setJitsiUrl(null); }; // Check if meeting can be joined (5 minutes before start until end) const canJoinMeeting = (meeting: ScheduledMeeting): boolean => { if (!meeting.start || !meeting.end) { console.warn('[Vision] canJoinMeeting: missing start or end', meeting); return false; } const now = new Date(); const start = new Date(meeting.start); const end = new Date(meeting.end); // Validate dates if (isNaN(start.getTime()) || isNaN(end.getTime())) { console.warn('[Vision] canJoinMeeting: invalid dates', { meetingId: meeting.id, start: meeting.start, end: meeting.end }); return false; } // Calculate 5 minutes before start const fiveMinutesBeforeStart = new Date(start); fiveMinutesBeforeStart.setMinutes(fiveMinutesBeforeStart.getMinutes() - 5); // Can join if now is between 5 minutes before start and end const canJoin = now >= fiveMinutesBeforeStart && now <= end; // Debug log for today's meetings const today = formatDateLocal(now); if (meeting.date === today) { console.log('[Vision] canJoinMeeting check:', { meetingId: meeting.id, title: meeting.title, date: meeting.date, time: meeting.time, now: now.toISOString(), nowLocal: now.toString(), start: start.toISOString(), startLocal: start.toString(), end: end.toISOString(), endLocal: end.toString(), fiveMinutesBeforeStart: fiveMinutesBeforeStart.toISOString(), fiveMinutesBeforeStartLocal: fiveMinutesBeforeStart.toString(), canJoin, nowVsFiveMinBefore: now >= fiveMinutesBeforeStart, nowVsEnd: now <= end, timeDiff: (now.getTime() - fiveMinutesBeforeStart.getTime()) / 1000 / 60, // minutes location: meeting.location }); } return canJoin; }; // Get meeting status for display const getMeetingStatus = (meeting: ScheduledMeeting): 'before' | 'active' | 'ended' | 'no-link' => { if (!meeting.location) { return 'no-link'; } if (!meeting.start || !meeting.end) { return 'no-link'; } const now = new Date(); const start = new Date(meeting.start); const end = new Date(meeting.end); // Validate dates if (isNaN(start.getTime()) || isNaN(end.getTime())) { return 'no-link'; } // Calculate 5 minutes before start const fiveMinutesBeforeStart = new Date(start); fiveMinutesBeforeStart.setMinutes(fiveMinutesBeforeStart.getMinutes() - 5); // Check if meeting has ended if (now > end) { return 'ended'; } // Check if meeting is active (5 minutes before start until end) if (now >= fiveMinutesBeforeStart && now <= end) { return 'active'; } // Before the meeting starts return 'before'; }; // Check if there's an active meeting for a group or mission (5 minutes before start until end) const hasActiveMeeting = (type: "group" | "mission", entityId: string): boolean => { const now = new Date(); // Find meetings for this group/mission const relevantMeetings = scheduledMeetings.filter(meeting => meeting.type === type && meeting.entityId === entityId ); // Check if any meeting is currently active (5 minutes before start until end) return relevantMeetings.some(meeting => { const start = new Date(meeting.start); const end = new Date(meeting.end); // Calculate 5 minutes before start const fiveMinutesBeforeStart = new Date(start); fiveMinutesBeforeStart.setMinutes(fiveMinutesBeforeStart.getMinutes() - 5); // Active if now is between 5 minutes before start and end return now >= fiveMinutesBeforeStart && now <= end; }); }; // Check if user can plan a meeting for a mission const canPlanMeetingForMission = (mission: Mission): boolean => { if (!session?.user?.id) return false; const userId = session.user.id; // Check if user is the creator if (mission.creatorId === userId || mission.creator?.id === userId) { return true; } // Check if user has one of the guardian roles const guardianRoles = ['gardien-temps', 'gardien-parole', 'gardien-memoire']; const hasGuardianRole = mission.missionUsers?.some( (mu) => (mu.userId === userId || mu.user?.id === userId) && guardianRoles.includes(mu.role) ); return !!hasGuardianRole; }; // Helper function to format date as YYYY-MM-DD in local timezone const formatDateLocal = (date: Date): string => { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }; // Get today's meetings const getTodayMeetings = (): ScheduledMeeting[] => { const today = new Date(); const todayStr = formatDateLocal(today); const todayMeetings = scheduledMeetings.filter(meeting => meeting.date === todayStr); // Debug log console.log('[Vision] Today meetings:', { todayStr, totalMeetings: scheduledMeetings.length, todayMeetingsCount: todayMeetings.length, todayMeetings: todayMeetings.map(m => ({ id: m.id, title: m.title, date: m.date, time: m.time, type: m.type, entityId: m.entityId, entityName: m.entityName, start: m.start, end: m.end, canJoin: canJoinMeeting(m) })) }); return todayMeetings; }; // Get meetings for a specific date const getMeetingsForDate = (date: Date): ScheduledMeeting[] => { const dateStr = formatDateLocal(date); return scheduledMeetings.filter(meeting => meeting.date === dateStr); }; // Helper function to get date from string const getDateFromString = (dateString: string): Date | null => { if (!dateString) return null; try { return new Date(dateString); } catch { return null; } }; // Format date-time to local format (YYYY-MM-DDTHH:mm) preserving local time const formatLocalDateTime = (date: Date): string => { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${year}-${month}-${day}T${hours}:${minutes}`; }; // Handle open meeting dialog const handleOpenMeetingDialog = (type: "group" | "mission", id: string, name: string) => { const defaultDate = selectedDate || new Date(); const startDate = new Date(defaultDate); startDate.setHours(new Date().getHours(), 0, 0, 0); const endDate = new Date(startDate); endDate.setHours(startDate.getHours() + 1); setMeetingForm({ type, entityId: id, entityName: name, title: "", start: formatLocalDateTime(startDate), end: formatLocalDateTime(endDate), allDay: false, description: "", recurrence: "none", }); setShowMeetingDialog(true); }; // Generate Jitsi URL based on meeting form (uses shared function) const getJitsiUrl = (): string => { if (!meetingForm.type || !meetingForm.entityId) return ""; return generateJitsiUrl(meetingForm.type, meetingForm.entityId); }; // Handle save meeting const handleSaveMeeting = async () => { if (!meetingForm.type || !meetingForm.entityId || !meetingForm.start || !meetingForm.end) { toast({ title: "Erreur", description: "Veuillez remplir tous les champs requis", variant: "destructive", }); return; } if (!session?.user?.id) { toast({ title: "Erreur", description: "Vous devez être connecté", variant: "destructive", }); return; } try { // Fetch user calendars to find the matching calendar const fetchCalendarsResponse = await fetch("/api/calendars"); if (!fetchCalendarsResponse.ok) { throw new Error("Impossible de charger les calendriers"); } const calendars = await fetchCalendarsResponse.json(); // Find the calendar for this mission or group let targetCalendar = null; if (meetingForm.type === "mission") { // For missions, look for calendar with missionId or name starting with "Mission:" targetCalendar = calendars.find((cal: any) => cal.missionId === meetingForm.entityId || cal.name === `Mission: ${meetingForm.entityName}` ); } else if (meetingForm.type === "group") { // For groups, look for calendar with name starting with "Groupe:" targetCalendar = calendars.find((cal: any) => cal.name === `Groupe: ${meetingForm.entityName}` ); } if (!targetCalendar) { toast({ title: "Erreur", description: `Calendrier ${meetingForm.type === "group" ? "du groupe" : "de la mission"} introuvable`, variant: "destructive", }); return; } const startDate = new Date(meetingForm.start); const endDate = new Date(meetingForm.end); const timeDiff = endDate.getTime() - startDate.getTime(); // Create events based on recurrence const eventsToCreate: Array<{ start: Date; end: Date }> = []; if (meetingForm.recurrence === "none") { // Single event eventsToCreate.push({ start: startDate, end: endDate }); } else { // Recurring events - create for next 12 occurrences const recurrenceCount = 12; let currentStart = new Date(startDate); for (let i = 0; i < recurrenceCount; i++) { const currentEnd = new Date(currentStart.getTime() + timeDiff); eventsToCreate.push({ start: new Date(currentStart), end: currentEnd }); // Calculate next occurrence if (meetingForm.recurrence === "daily") { currentStart.setDate(currentStart.getDate() + 1); } else if (meetingForm.recurrence === "weekly") { currentStart.setDate(currentStart.getDate() + 7); } else if (meetingForm.recurrence === "monthly") { currentStart.setMonth(currentStart.getMonth() + 1); } } } // All meetings in Vision page are video conferences, so always generate Jitsi URL const jitsiUrlToSave = getJitsiUrl(); // Create all events via API const eventPromises = eventsToCreate.map(async ({ start, end }) => { const response = await fetch("/api/events", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ title: meetingForm.title || `Réunion ${meetingForm.type === "group" ? "du groupe" : "de la mission"}: ${meetingForm.entityName}`, description: meetingForm.description || null, start: start.toISOString(), end: end.toISOString(), allDay: meetingForm.allDay, location: jitsiUrlToSave, calendarId: targetCalendar.id, }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || "Erreur lors de la création de l'événement"); } return await response.json(); }); await Promise.all(eventPromises); // Refresh meetings from database after creation await fetchMeetings(false); // Don't show loading spinner setShowMeetingDialog(false); setMeetingForm({ type: "", entityId: "", entityName: "", title: "", start: "", end: "", allDay: false, description: "", recurrence: "none", }); toast({ title: "Succès", description: `${eventsToCreate.length} réunion${eventsToCreate.length > 1 ? 's' : ''} planifiée${eventsToCreate.length > 1 ? 's' : ''} avec succès`, }); } catch (error) { console.error("Error saving meeting:", error); toast({ title: "Erreur", description: error instanceof Error ? error.message : "Erreur lors de la planification de la réunion", variant: "destructive", }); } }; // Handle delete meeting const handleDeleteMeeting = async (meetingId: string) => { try { const response = await fetch(`/api/events/${meetingId}`, { method: "DELETE", }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || "Erreur lors de la suppression"); } // Refresh meetings from database after deletion await fetchMeetings(false); // Don't show loading spinner toast({ title: "Succès", description: "Réunion supprimée", }); } catch (error) { console.error("Error deleting meeting:", error); toast({ title: "Erreur", description: error instanceof Error ? error.message : "Erreur lors de la suppression", variant: "destructive", }); } }; // Format date for display const formatDate = (dateStr: string): string => { const date = new Date(dateStr); return date.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' }); }; // Format time range for display (start - end) const formatTimeRange = (meeting: ScheduledMeeting): string => { if (meeting.isAllDay) { return "Toute la journée"; } if (!meeting.start || !meeting.end) { return meeting.time || ""; } const start = new Date(meeting.start); const end = new Date(meeting.end); const startTime = start.toTimeString().slice(0, 5); const endTime = end.toTimeString().slice(0, 5); return `${startTime} - ${endTime}`; }; // Show loading state if (status === "loading" || loading) { return (

Chargement...

); } // Show Jitsi iframe if conference is selected if (selectedConference && jitsiUrl) { return (
{/* Jitsi iframe - full height */}
); } // Show list of groups and missions const todayMeetings = getTodayMeetings(); return (
{/* Header */}

Espaces de réunion

Voir Grand, Commencer Petit et Rester Constant

{/* Today's Meetings Section */} {todayMeetings.length > 0 && (

Réunions du jour ({todayMeetings.length})

{todayMeetings.map((meeting) => (

{meeting.title || `${meeting.type === "group" ? "Groupe" : "Mission"}: ${meeting.entityName}`}

{meeting.type === "group" ? "Groupe" : "Mission"}: {meeting.entityName}

{formatTimeRange(meeting)}

{(() => { const status = getMeetingStatus(meeting); if (status === 'active') { return ( ); } else if (status === 'before') { return ( Bientôt disponible ); } else if (status === 'ended') { return ( Terminé ); } else { return ( Pas de lien ); } })()}
))}
)} {/* Calendar Section */}

Calendrier des réunions

{/* Colonne calendrier (étroite) */}