NeahStable/app/vision/page.tsx
2026-01-21 00:15:11 +01:00

1447 lines
56 KiB
TypeScript

"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<Group[]>([]);
const [missions, setMissions] = useState<Mission[]>([]);
const [loading, setLoading] = useState(true);
const [selectedConference, setSelectedConference] = useState<{
type: ConferenceType;
id: string;
name: string;
} | null>(null);
const [jitsiUrl, setJitsiUrl] = useState<string | null>(null);
const [scheduledMeetings, setScheduledMeetings] = useState<ScheduledMeeting[]>([]);
const [meetingsLoading, setMeetingsLoading] = useState(false);
const [showMeetingDialog, setShowMeetingDialog] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(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();
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 (
<main className="w-full h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<Loader2 className="h-12 w-12 animate-spin text-blue-600 mx-auto mb-4" />
<p className="text-gray-600">Chargement...</p>
</div>
</main>
);
}
// Show Jitsi iframe if conference is selected
if (selectedConference && jitsiUrl) {
return (
<main className="w-full h-[calc(100vh-3rem)] bg-black flex flex-col mt-12 absolute top-0 left-0 right-0">
{/* Jitsi iframe - full height */}
<div className="flex-1 overflow-hidden w-full h-full">
<ResponsiveIframe
src={jitsiUrl}
allow="camera; microphone; fullscreen; display-capture; autoplay; clipboard-write; encrypted-media"
/>
</div>
</main>
);
}
// Show list of groups and missions
const todayMeetings = getTodayMeetings();
return (
<main className="w-full h-screen bg-gray-50 overflow-auto">
<div className="w-full px-4 pt-20 pb-4">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900 mb-2">
Espaces de réunion
</h1>
<p className="text-gray-600">
Voir Grand, Commencer Petit et Rester Constant
</p>
</div>
{/* Today's Meetings Section */}
{todayMeetings.length > 0 && (
<div className="mb-8 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-blue-600" />
<h2 className="text-lg font-semibold text-blue-900">
Réunions du jour ({todayMeetings.length})
</h2>
</div>
</div>
<div className="space-y-2">
{todayMeetings.map((meeting) => (
<div
key={meeting.id}
className="bg-white rounded-lg border border-blue-200 p-3 flex items-center justify-between"
>
<div className="flex items-center gap-3 flex-1">
<div className="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center">
<Clock className="h-5 w-5 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-medium text-gray-900">
{meeting.title || `${meeting.type === "group" ? "Groupe" : "Mission"}: ${meeting.entityName}`}
</h3>
<p className="text-sm text-gray-500">
{meeting.type === "group" ? "Groupe" : "Mission"}: {meeting.entityName}
</p>
<p className="text-sm text-gray-500">
{formatTimeRange(meeting)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{(() => {
const status = getMeetingStatus(meeting);
if (status === 'active') {
return (
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-700 text-white"
onClick={() => handleConferenceClick(meeting.type, meeting.entityId, meeting.entityName, meeting.location!)}
>
<Video className="h-4 w-4 mr-1" />
Rejoindre
</Button>
);
} else if (status === 'before') {
return (
<span className="text-xs text-gray-400">
Bientôt disponible
</span>
);
} else if (status === 'ended') {
return (
<span className="text-xs text-gray-400">
Terminé
</span>
);
} else {
return (
<span className="text-xs text-gray-400">
Pas de lien
</span>
);
}
})()}
</div>
</div>
))}
</div>
</div>
)}
{/* Calendar Section */}
<div className="mb-8">
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-2 mb-4">
<Calendar className="h-5 w-5 text-purple-600" />
<h2 className="text-xl font-semibold text-gray-900">
Calendrier des réunions
</h2>
</div>
<div className="flex flex-col lg:flex-row gap-6">
{/* Colonne calendrier (étroite) */}
<div id="vision-calendar" className="w-full lg:w-80 lg:flex-shrink-0">
<style dangerouslySetInnerHTML={{__html: `
#vision-calendar {
color: #111827 !important;
}
#vision-calendar .rdp {
--rdp-cell-size: 2.5rem;
--rdp-accent-color: #2563eb;
--rdp-background-color: white;
--rdp-accent-color-dark: #1d4ed8;
--rdp-background-color-dark: #f3f4f6;
--rdp-outline: 2px solid var(--rdp-accent-color);
--rdp-outline-selected: 2px solid var(--rdp-accent-color);
margin: 0;
}
#vision-calendar .rdp-caption {
color: #111827 !important;
background-color: white !important;
}
#vision-calendar .rdp-caption_label {
color: #111827 !important;
font-weight: 600 !important;
font-size: 0.875rem !important;
background-color: white !important;
}
#vision-calendar .rdp-nav {
background-color: white !important;
}
#vision-calendar .rdp-nav_button {
color: #374151 !important;
background-color: white !important;
border: 1px solid #e5e7eb !important;
}
#vision-calendar .rdp-nav_button:hover {
color: #111827 !important;
background-color: #f9fafb !important;
}
#vision-calendar .rdp-nav_button svg,
#vision-calendar .rdp-nav_button path {
color: #374151 !important;
stroke: #374151 !important;
fill: #374151 !important;
}
#vision-calendar .rdp-nav_button:hover svg,
#vision-calendar .rdp-nav_button:hover path {
color: #111827 !important;
stroke: #111827 !important;
fill: #111827 !important;
}
#vision-calendar .rdp-head_cell {
color: #6b7280 !important;
font-weight: 500 !important;
background-color: white !important;
}
#vision-calendar .rdp-day {
color: #1f2937 !important;
background-color: white !important;
width: 2.5rem !important;
height: 2.5rem !important;
font-size: 0.875rem !important;
border: 1px solid transparent !important;
}
#vision-calendar .rdp-day:hover:not(.rdp-day_disabled):not(.rdp-day_selected):not(.hasMeeting) {
background-color: #f3f4f6 !important;
color: #111827 !important;
}
#vision-calendar .rdp-day_today {
background-color: #DBEAFE !important;
color: #1d4ed8 !important;
border: 2px solid #3b82f6 !important;
font-weight: 600 !important;
}
#vision-calendar .rdp-day_today:hover {
background-color: #bfdbfe !important;
color: #1e40af !important;
}
/* Selected day uses default primary color (black) */
#vision-calendar .rdp-day_selected {
font-weight: 600 !important;
}
#vision-calendar .rdp-day_outside {
color: #9ca3af !important;
background-color: white !important;
}
#vision-calendar .rdp-day_outside:hover {
background-color: #f9fafb !important;
color: #6b7280 !important;
}
#vision-calendar .rdp-day.hasMeeting {
background-color: #2563eb !important;
color: white !important;
font-weight: 600 !important;
}
#vision-calendar .rdp-day.hasMeeting:hover {
background-color: #1d4ed8 !important;
color: white !important;
}
#vision-calendar .rdp-day.hasMeeting.rdp-day_today {
background-color: #1d4ed8 !important;
color: white !important;
border: 2px solid #1e40af !important;
}
#vision-calendar .rdp-day.hasMeeting.rdp-day_selected {
background-color: #1e40af !important;
color: white !important;
}
`}} />
<CalendarComponent
mode="single"
selected={selectedDate}
onSelect={setSelectedDate}
className="rounded-md border"
modifiers={{
hasMeeting: (date) => {
if (!date) return false;
return getMeetingsForDate(date).length > 0;
},
}}
modifiersClassNames={{
hasMeeting: "hasMeeting",
}}
/>
</div>
{/* Colonne invisible pour espacer calendrier et liste des réunions */}
<div className="hidden lg:block w-8" />
<div className="flex-1">
{selectedDate && (
<div>
<h3 className="font-semibold text-gray-900 mb-3">
Réunions du {selectedDate.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })}
</h3>
{getMeetingsForDate(selectedDate).length === 0 ? (
<p className="text-gray-500 text-sm">Aucune réunion planifiée</p>
) : (
<div className="space-y-2">
{getMeetingsForDate(selectedDate).map((meeting) => (
<div
key={meeting.id}
className="bg-gray-50 rounded-lg border border-gray-200 p-3"
>
<div className="flex-1">
<h4 className="font-medium text-gray-900">
{meeting.title || `${meeting.type === "group" ? "Groupe" : "Mission"}: ${meeting.entityName}`}
</h4>
<p className="text-sm text-gray-500">
{meeting.type === "group" ? "Groupe" : "Mission"}: {meeting.entityName}
</p>
<p className="text-sm text-gray-500">
{formatTimeRange(meeting)}
</p>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
{/* Groups Section */}
<div className="mb-8">
<div className="flex items-center gap-2 mb-4">
<Users className="h-5 w-5 text-blue-600" />
<h2 className="text-xl font-semibold text-gray-900">
Groupes ({groups.length})
</h2>
</div>
{groups.length === 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center">
<Users className="h-12 w-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-500">Aucun groupe disponible</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{groups.map((group) => (
<div
key={group.id}
className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<div
className="h-10 w-10 rounded-full flex items-center justify-center"
style={{
backgroundColor: group.calendarColor || '#DBEAFE',
}}
>
<Users className="h-5 w-5" style={{ color: group.calendarColor ? '#ffffff' : '#2563EB' }} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 truncate">
{group.name}
</h3>
</div>
</div>
<div className="flex items-center gap-2 ml-2">
{hasActiveMeeting("group", group.id) && (
<Button
size="sm"
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white"
onClick={(e) => {
e.stopPropagation();
handleConferenceClick("group", group.id, group.name);
}}
>
<Video className="h-4 w-4" />
Rejoindre
</Button>
)}
<Button
size="sm"
className="flex items-center gap-2 bg-gray-100 hover:bg-gray-200 text-gray-700 border border-gray-300"
onClick={(e) => {
e.stopPropagation();
setSelectedDate(new Date());
handleOpenMeetingDialog("group", group.id, group.name);
}}
title="Planifier une réunion"
>
<Calendar className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Missions Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<FolderKanban className="h-5 w-5 text-purple-600" />
<h2 className="text-xl font-semibold text-gray-900">
Missions ({missions.length})
</h2>
</div>
{missions.length === 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center">
<FolderKanban className="h-12 w-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-500">Aucune mission disponible</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{missions.map((mission) => (
<div
key={mission.id}
className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
{mission.logoUrl ? (
<div className="h-10 w-10 rounded-md overflow-hidden flex-shrink-0">
<img
src={mission.logoUrl}
alt={mission.name}
className="h-full w-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
) : (
<div className="h-10 w-10 rounded-md bg-purple-100 flex items-center justify-center flex-shrink-0">
<FolderKanban className="h-5 w-5 text-purple-600" />
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 truncate">
{mission.name}
</h3>
</div>
</div>
<div className="flex items-center gap-2 ml-2">
{hasActiveMeeting("mission", mission.id) && (
<Button
size="sm"
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white"
onClick={(e) => {
e.stopPropagation();
handleConferenceClick("mission", mission.id, mission.name);
}}
>
<Video className="h-4 w-4" />
Rejoindre
</Button>
)}
{canPlanMeetingForMission(mission) && (
<Button
size="sm"
className="flex items-center gap-2 bg-gray-100 hover:bg-gray-200 text-gray-700 border border-gray-300"
onClick={(e) => {
e.stopPropagation();
setSelectedDate(new Date());
handleOpenMeetingDialog("mission", mission.id, mission.name);
}}
title="Planifier une réunion"
>
<Calendar className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* Meeting Dialog */}
<Dialog open={showMeetingDialog} onOpenChange={setShowMeetingDialog}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-gray-800">Planifier une réunion</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid gap-2">
<Label htmlFor="meeting-title" className="text-base font-semibold text-gray-800">Titre</Label>
<Input
id="meeting-title"
value={meetingForm.title}
onChange={(e) => setMeetingForm({ ...meetingForm, title: e.target.value })}
placeholder={`Réunion ${meetingForm.type === "group" ? "du groupe" : "de la mission"}`}
className="bg-white text-gray-900"
/>
</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(meetingForm.start)}
onChange={(date: Date | null) => {
if (date) {
const newStart = new Date(date);
if (!meetingForm.allDay) {
const currentStart = getDateFromString(meetingForm.start);
if (currentStart) {
newStart.setHours(currentStart.getHours(), currentStart.getMinutes());
}
}
// Update end date to match start date
const newEnd = new Date(newStart);
if (!meetingForm.allDay) {
const currentEnd = getDateFromString(meetingForm.end);
if (currentEnd) {
newEnd.setHours(currentEnd.getHours(), currentEnd.getMinutes());
} else {
// Default to 1 hour after start if no end time set
newEnd.setHours(newStart.getHours() + 1, newStart.getMinutes());
}
}
setMeetingForm({
...meetingForm,
start: formatLocalDateTime(newStart),
end: formatLocalDateTime(newEnd)
});
}
}}
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>
{!meetingForm.allDay && (
<DatePicker
selected={getDateFromString(meetingForm.start)}
onChange={(date: Date | null) => {
if (date) {
const currentStart = getDateFromString(meetingForm.start) || new Date();
const newStart = new Date(currentStart);
newStart.setHours(date.getHours(), date.getMinutes());
// Update end date to match start date, keeping end time
const currentEnd = getDateFromString(meetingForm.end) || new Date();
const newEnd = new Date(newStart);
newEnd.setHours(currentEnd.getHours(), currentEnd.getMinutes());
// If end time is before start time, set end to 1 hour after start
if (newEnd <= newStart) {
newEnd.setHours(newStart.getHours() + 1, newStart.getMinutes());
}
setMeetingForm({
...meetingForm,
start: formatLocalDateTime(newStart),
end: formatLocalDateTime(newEnd)
});
}
}}
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(meetingForm.end)}
onChange={(date: Date | null) => {
if (date) {
const newEnd = new Date(date);
if (!meetingForm.allDay) {
const currentEnd = getDateFromString(meetingForm.end);
if (currentEnd) {
newEnd.setHours(currentEnd.getHours(), currentEnd.getMinutes());
}
}
setMeetingForm({ ...meetingForm, end: formatLocalDateTime(newEnd) });
}
}}
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(meetingForm.start) || undefined}
/>
</div>
{!meetingForm.allDay && (
<DatePicker
selected={getDateFromString(meetingForm.end)}
onChange={(date: Date | null) => {
if (date) {
const currentEnd = getDateFromString(meetingForm.end) || new Date();
const newEnd = new Date(currentEnd);
newEnd.setHours(date.getHours(), date.getMinutes());
setMeetingForm({ ...meetingForm, end: formatLocalDateTime(newEnd) });
}
}}
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 className="flex items-center space-x-2">
<Checkbox
id="allDay"
checked={meetingForm.allDay}
onCheckedChange={(checked) =>
setMeetingForm({ ...meetingForm, 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">Description</Label>
<Textarea
value={meetingForm.description}
onChange={(e) =>
setMeetingForm({ ...meetingForm, description: e.target.value })
}
placeholder="Ajouter une description"
className="bg-white text-gray-900"
rows={3}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-800">Récurrence</Label>
<Select
value={meetingForm.recurrence}
onValueChange={(value: "none" | "daily" | "weekly" | "monthly") =>
setMeetingForm({ ...meetingForm, recurrence: value })
}
>
<SelectTrigger className="w-full bg-white text-gray-900">
<SelectValue placeholder="Sélectionner une récurrence" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Aucune récurrence</SelectItem>
<SelectItem value="daily">Tous les jours</SelectItem>
<SelectItem value="weekly">Toutes les semaines</SelectItem>
<SelectItem value="monthly">Tous les mois</SelectItem>
</SelectContent>
</Select>
</div>
<div className="bg-gray-50 p-3 rounded-lg">
<p className="text-sm text-gray-600">
<strong>{meetingForm.type === "group" ? "Groupe" : "Mission"}:</strong> {meetingForm.entityName}
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowMeetingDialog(false);
setMeetingForm({
type: "",
entityId: "",
entityName: "",
title: "",
start: "",
end: "",
allDay: false,
description: "",
recurrence: "none",
});
}}
className="bg-white hover:bg-gray-50 text-gray-900 border-gray-200"
>
Annuler
</Button>
<Button
onClick={handleSaveMeeting}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
Planifier
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
);
}