1452 lines
56 KiB
TypeScript
1452 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();
|
|
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 (
|
|
<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>
|
|
);
|
|
}
|