diff --git a/backend/src/controllers/permanence.controller.ts b/backend/src/controllers/permanence.controller.ts index 92ca549..5aee1f9 100644 --- a/backend/src/controllers/permanence.controller.ts +++ b/backend/src/controllers/permanence.controller.ts @@ -374,7 +374,7 @@ export const sendHourlyNotificationToUsers: AppRequestHandler = async (_req, res return; } - permanence_service.sendNotifications(notifications); + await permanence_service.sendNotifications(notifications); Ok(res, { msg: 'Notifications horaires envoyées avec succès' }); }; @@ -391,3 +391,44 @@ export const sendDailyNotificationToUsers: AppRequestHandler = async (_req, res) Ok(res, { msg: 'Notifications quotidiennes envoyées avec succès' }); }; + +export const sendConcurrentPermanenceNotifications: AppRequestHandler = async (_req, res) => { + try { + const notifications = await permanence_service.getConcurrentPermanenceNotifications(); + await permanence_service.sendConcurrentPermanenceNotifications(notifications); + Ok(res, { msg: `${notifications.length} notification(s) de conflit envoyée(s).` }); + } catch (err) { + console.error(err); + Error(res, { msg: 'Erreur lors de l’envoi des notifications de permanences concurrentes' }); + } +}; + +export const purgeConcurrentPermanences: AppRequestHandler = async (_req, res) => { + try { + const result = await permanence_service.purgeConcurrentPermanences(); + Ok(res, { + data: result, + msg: `${result.removedRegistrations} inscription(s) supprimée(s) pour ${result.affectedUsers} utilisateur(s).`, + }); + } catch (err) { + console.error(err); + Error(res, { msg: 'Erreur lors de la purge des permanences concurrentes' }); + } +}; + +export const getConcurrentPermanences: AppRequestHandler = async (req, res) => { + const userId = req.user?.userId; + + if (!userId) { + Error(res, { msg: 'Utilisateur non identifié' }); + return; + } + + try { + const status = await permanence_service.getConcurrentPermanencesStatus(Number(userId)); + Ok(res, { data: status }); + } catch (err) { + console.error(err); + Error(res, { msg: 'Erreur lors de la vérification des permanences concurrentes' }); + } +}; diff --git a/backend/src/email/email.registry.ts b/backend/src/email/email.registry.ts index f582ca5..90f13a7 100644 --- a/backend/src/email/email.registry.ts +++ b/backend/src/email/email.registry.ts @@ -1,4 +1,9 @@ -import type { PermanenceEmailData, TemplateRenderer, TeamAssignmentEmailData } from '../types/email'; +import type { + ConcurrentPermanencesEmailData, + PermanenceEmailData, + TemplateRenderer, + TeamAssignmentEmailData, +} from '../types/email'; export const templateResetPassword = 'reset-password.html'; const templateNotebook = 'notebook.html'; @@ -7,6 +12,7 @@ const templateWelcome = 'welcome.html'; const templateNotifyNews = 'notify-news.html'; const templateNotifyTentConfirmation = 'notify-tent-confirmation.html'; const templateNotifyPermanenceReminder = 'notify-permanence-reminder.html'; +const templateNotifyConcurrentPermanences = 'notify-concurrent-permanences.html'; const templateNotifyTeamAssignment = 'notify-team-assignment.html'; const templateMentorReminder = 'mentor-reminder.html'; @@ -80,6 +86,10 @@ export const templateRenderers: Record = { return typedData; }, }, + templateNotifyConcurrentPermanences: { + fileName: templateNotifyConcurrentPermanences, + buildData: (data) => data as ConcurrentPermanencesEmailData, + }, templateMentorReminder: { fileName: templateMentorReminder, buildData: () => { diff --git a/backend/src/email/templates/notify-concurrent-permanences.html b/backend/src/email/templates/notify-concurrent-permanences.html new file mode 100644 index 0000000..ca13bc2 --- /dev/null +++ b/backend/src/email/templates/notify-concurrent-permanences.html @@ -0,0 +1,116 @@ + + + + + + Intégration UTT + + + + + + +
+ + + + + + + + + + + + + +
+ Logo Intégration UTT +
+ INTEGRATION UTT +
+

+ Permanences concurrentes +

+

+ Attention, certaines de vos permanences se deroulent sur des creneaux qui se + chevauchent : +

+ {{#each permanences}} +
+

{{name}}

+

Du {{startAt}} au {{endAt}}

+

Lieu : {{location}}

+
+ {{/each}} +

+ Rendez-vous sur la page « Mes permanences » pour verifier vos + inscriptions. +

+

+ Après un shotgun, vous avez jusqu'au lendemain midi pour supprimer + les doublons, sinon des permanences seront + retirées de manière aléatoire pour éliminer les conflits, et les + permanences libérées seront à nouveau shotgun le lendemain. +

+
+

+ Retrouve des informations utiles sur notre Instagram ! +

+
+ + Instagram + +
+

Cordialement,
L'équipe intégration UTT

+

+ Si vous avez des questions, n'hésitez pas à + nous contacter. +

+
+
+ + diff --git a/backend/src/routes/automation.routes.ts b/backend/src/routes/automation.routes.ts index 214f4a4..1561de1 100644 --- a/backend/src/routes/automation.routes.ts +++ b/backend/src/routes/automation.routes.ts @@ -6,5 +6,10 @@ const automationRoutes = express.Router(); // Permanences routes automationRoutes.post('/permanence/notification/hourly', permanenceController.sendHourlyNotificationToUsers); automationRoutes.post('/permanence/notification/daily', permanenceController.sendDailyNotificationToUsers); +automationRoutes.post( + '/permanence/concurrent/notification', + permanenceController.sendConcurrentPermanenceNotifications, +); +automationRoutes.post('/permanence/concurrent/purge', permanenceController.purgeConcurrentPermanences); export default automationRoutes; diff --git a/backend/src/routes/permanences.routes.ts b/backend/src/routes/permanences.routes.ts index 50fb6e3..162884d 100644 --- a/backend/src/routes/permanences.routes.ts +++ b/backend/src/routes/permanences.routes.ts @@ -1,33 +1,47 @@ -import express from "express"; -import multer from "multer"; -import * as permanenceController from "../controllers/permanence.controller"; -import { isRespoMiddleware } from "../middlewares/respoperm.middleware"; -import { checkRole } from "../middlewares/user.middleware"; +import express from 'express'; +import multer from 'multer'; +import * as permanenceController from '../controllers/permanence.controller'; +import { isRespoMiddleware } from '../middlewares/respoperm.middleware'; +import { checkRole } from '../middlewares/user.middleware'; const permanenceRouter = express.Router(); -const upload = multer({ dest: "uploads/permcsv/" }); +const upload = multer({ dest: 'uploads/permcsv/' }); // Admin routes -permanenceRouter.post("/admin/permanence", checkRole("Admin", ["Respo CE"]), permanenceController.createPermanence); -permanenceRouter.delete("/admin/permanence", checkRole("Admin", ["Respo CE"]), permanenceController.deletePermanence); -permanenceRouter.post("/admin/updatepermanence", checkRole("Admin", ["Respo CE"]), permanenceController.updatePermanence); -permanenceRouter.post("/admin/open", checkRole("Admin", ["Respo CE"]), permanenceController.openPermanence); -permanenceRouter.post("/admin/close", checkRole("Admin", ["Respo CE"]), permanenceController.closePermanence); -permanenceRouter.get("/admin/permanences", checkRole("Admin", ["Respo CE"]), permanenceController.getAllPermanences); -permanenceRouter.get("/admin/users", checkRole("Admin", ["Respo CE"]), permanenceController.getUsersInPermanence); -permanenceRouter.post("/admin/add", checkRole("Admin", ["Respo CE"]), permanenceController.addUserToPermanence); -permanenceRouter.post("/admin/remove", checkRole("Admin", ["Respo CE"]), permanenceController.removeUserToPermanence); -permanenceRouter.post("/admin/importpermanences", checkRole("Admin", ["Respo CE"]), upload.single("file"), permanenceController.uploadPermanencesCSV); -permanenceRouter.post("/admin/claimedmember", checkRole("Admin", ["Respo CE"]), permanenceController.claimMember); +permanenceRouter.post('/admin/permanence', checkRole('Admin', ['Respo CE']), permanenceController.createPermanence); +permanenceRouter.delete('/admin/permanence', checkRole('Admin', ['Respo CE']), permanenceController.deletePermanence); +permanenceRouter.post( + '/admin/updatepermanence', + checkRole('Admin', ['Respo CE']), + permanenceController.updatePermanence, +); +permanenceRouter.post('/admin/open', checkRole('Admin', ['Respo CE']), permanenceController.openPermanence); +permanenceRouter.post('/admin/close', checkRole('Admin', ['Respo CE']), permanenceController.closePermanence); +permanenceRouter.get('/admin/permanences', checkRole('Admin', ['Respo CE']), permanenceController.getAllPermanences); +permanenceRouter.get('/admin/users', checkRole('Admin', ['Respo CE']), permanenceController.getUsersInPermanence); +permanenceRouter.post('/admin/add', checkRole('Admin', ['Respo CE']), permanenceController.addUserToPermanence); +permanenceRouter.post('/admin/remove', checkRole('Admin', ['Respo CE']), permanenceController.removeUserToPermanence); +permanenceRouter.post( + '/admin/importpermanences', + checkRole('Admin', ['Respo CE']), + upload.single('file'), + permanenceController.uploadPermanencesCSV, +); +permanenceRouter.post('/admin/claimedmember', checkRole('Admin', ['Respo CE']), permanenceController.claimMember); //Respo de perm routes -permanenceRouter.get("/respo/respodetails", isRespoMiddleware, permanenceController.getRespoPermanencesWithMembers); -permanenceRouter.post("/respo/claimedmember", isRespoMiddleware, permanenceController.claimMember); +permanenceRouter.get('/respo/respodetails', isRespoMiddleware, permanenceController.getRespoPermanencesWithMembers); +permanenceRouter.post('/respo/claimedmember', isRespoMiddleware, permanenceController.claimMember); // Student routes -permanenceRouter.get("/user/permanences", checkRole("Student", []), permanenceController.getOpenPermanences); -permanenceRouter.post("/user/apply", checkRole("Student", []), permanenceController.applyToPermanence); -permanenceRouter.post("/user/leave", checkRole("Student", []), permanenceController.leavePermanence); -permanenceRouter.get("/user/me", checkRole("Student", []), permanenceController.getMyPermanences); -permanenceRouter.get("/user/isrespo", permanenceController.isUserRespo); +permanenceRouter.get('/user/permanences', checkRole('Student', []), permanenceController.getOpenPermanences); +permanenceRouter.get( + '/user/concurrent/status', + checkRole('Student', []), + permanenceController.getConcurrentPermanences, +); +permanenceRouter.post('/user/apply', checkRole('Student', []), permanenceController.applyToPermanence); +permanenceRouter.post('/user/leave', checkRole('Student', []), permanenceController.leavePermanence); +permanenceRouter.get('/user/me', checkRole('Student', []), permanenceController.getMyPermanences); +permanenceRouter.get('/user/isrespo', permanenceController.isUserRespo); export default permanenceRouter; diff --git a/backend/src/services/permanence.service.ts b/backend/src/services/permanence.service.ts index aa9a36e..6f182db 100644 --- a/backend/src/services/permanence.service.ts +++ b/backend/src/services/permanence.service.ts @@ -1,8 +1,14 @@ import { and, eq, inArray, sql } from 'drizzle-orm'; import fs from 'fs'; import Papa from 'papaparse'; -import type { PermanenceEmailData } from '../types/email'; -import type { CsvPermanence, LightUser, Notification, Permanence } from '../types/permanence'; +import type { ConcurrentPermanencesEmailData, PermanenceEmailData } from '../types/email'; +import type { + ConcurrentPermanenceNotification, + CsvPermanence, + LightUser, + Notification, + Permanence, +} from '../types/permanence'; import { db } from '../database/db'; import { AlreadyRegisteredError, @@ -435,8 +441,8 @@ export const getDailyNotifications = async (): Promise => { export const getHourlyNotifications = async (): Promise => { const permanences = await db.query.permanenceSchema.findMany({ where: sql` - ${permanenceSchema.start_at} >= date_trunc('hour', now()) + interval '1 hour' - AND ${permanenceSchema.start_at} < date_trunc('hour', now()) + interval '2 hour' + ${permanenceSchema.start_at} >= now() + interval '1 hour' + AND ${permanenceSchema.start_at} < now() + interval '1 hour 5 minutes' `, orderBy: permanenceSchema.start_at, }); @@ -444,6 +450,171 @@ export const getHourlyNotifications = async (): Promise => { return getMembersFromPermanences(permanences); }; +const findConcurrentItems = ( + items: T[], + getStartAt: (item: T) => Date | null, + getEndAt: (item: T) => Date | null, +): T[] => { + const concurrentItems = new Set(); + + for (let firstIndex = 0; firstIndex < items.length; firstIndex += 1) { + const first = items[firstIndex]; + const firstStartAt = getStartAt(first); + const firstEndAt = getEndAt(first); + if (!firstStartAt || !firstEndAt) continue; + + for (let secondIndex = firstIndex + 1; secondIndex < items.length; secondIndex += 1) { + const second = items[secondIndex]; + const secondStartAt = getStartAt(second); + const secondEndAt = getEndAt(second); + if (!secondStartAt || !secondEndAt) continue; + + if (firstStartAt < secondEndAt && secondStartAt < firstEndAt) { + concurrentItems.add(first); + concurrentItems.add(second); + } + } + } + + return items.filter((item) => concurrentItems.has(item)); +}; + +export const getConcurrentPermanenceNotifications = async (): Promise => { + const registrations = await db + .select({ + userId: userSchema.id, + email: userSchema.email, + permanence: permanenceSchema, + }) + .from(userPermanenceSchema) + .innerJoin(userSchema, eq(userSchema.id, userPermanenceSchema.user_id)) + .innerJoin(permanenceSchema, eq(permanenceSchema.id, userPermanenceSchema.permanence_id)); + + const registrationsByUser = new Map(); + for (const registration of registrations) { + if (!registration.email) continue; + const userRegistration = registrationsByUser.get(registration.userId) ?? { + email: registration.email, + permanences: [], + }; + userRegistration.permanences.push(registration.permanence); + registrationsByUser.set(registration.userId, userRegistration); + } + + return [...registrationsByUser.entries()].flatMap(([userId, userRegistration]) => { + const { email, permanences } = userRegistration; + const concurrentPermanences = findConcurrentItems( + permanences, + (permanence) => permanence.start_at, + (permanence) => permanence.end_at, + ); + return concurrentPermanences.length > 0 ? [{ userId, email, permanences: concurrentPermanences }] : []; + }); +}; + +export const getConcurrentPermanencesStatus = async (userId: number) => { + const permanences = await db + .select({ + id: permanenceSchema.id, + name: permanenceSchema.name, + description: permanenceSchema.description, + location: permanenceSchema.location, + start_at: permanenceSchema.start_at, + end_at: permanenceSchema.end_at, + capacity: permanenceSchema.capacity, + is_open: permanenceSchema.is_open, + difficulty: permanenceSchema.difficulty, + }) + .from(userPermanenceSchema) + .innerJoin(permanenceSchema, eq(permanenceSchema.id, userPermanenceSchema.permanence_id)) + .where(eq(userPermanenceSchema.user_id, userId)); + + const concurrentPermanences = findConcurrentItems( + permanences, + (permanence) => permanence.start_at, + (permanence) => permanence.end_at, + ); + + return { + concurrentPermanences: concurrentPermanences.length > 0, + permanences: concurrentPermanences, + }; +}; + +export const purgeConcurrentPermanences = async () => { + return db.transaction(async (tx) => { + await tx.execute(sql`LOCK TABLE user_permanences IN EXCLUSIVE MODE`); + + const registrations = await tx + .select({ + userId: userPermanenceSchema.user_id, + permanenceId: permanenceSchema.id, + startAt: permanenceSchema.start_at, + endAt: permanenceSchema.end_at, + difficulty: permanenceSchema.difficulty, + }) + .from(userPermanenceSchema) + .innerJoin(permanenceSchema, eq(permanenceSchema.id, userPermanenceSchema.permanence_id)); + + const registrationsByUser = new Map(); + for (const registration of registrations) { + if (registration.userId === null) continue; + + const userRegistrations = registrationsByUser.get(registration.userId) ?? []; + userRegistrations.push(registration); + registrationsByUser.set(registration.userId, userRegistrations); + } + + let removedRegistrations = 0; + let affectedUsers = 0; + + for (const [userId, userRegistrations] of registrationsByUser) { + let removedForUser = false; + + while (true) { + const conflictingRegistrations = findConcurrentItems( + userRegistrations, + (registration) => registration.startAt, + (registration) => registration.endAt, + ); + + if (conflictingRegistrations.length === 0) break; + + const lowestDifficulty = Math.min( + ...conflictingRegistrations.map((registration) => registration.difficulty ?? 0), + ); + const lowestDifficultyRegistrations = conflictingRegistrations.filter( + (registration) => (registration.difficulty ?? 0) === lowestDifficulty, + ); + const permanenceToRemove = + lowestDifficultyRegistrations[Math.floor(Math.random() * lowestDifficultyRegistrations.length)]; + + await tx + .delete(userPermanenceSchema) + .where( + and( + eq(userPermanenceSchema.user_id, userId), + eq(userPermanenceSchema.permanence_id, permanenceToRemove.permanenceId), + ), + ); + await tx + .update(permanenceSchema) + .set({ capacity: sql`capacity + 1` }) + .where(eq(permanenceSchema.id, permanenceToRemove.permanenceId)); + + const registrationIndex = userRegistrations.indexOf(permanenceToRemove); + userRegistrations.splice(registrationIndex, 1); + removedRegistrations += 1; + removedForUser = true; + } + + if (removedForUser) affectedUsers += 1; + } + + return { removedRegistrations, affectedUsers }; + }); +}; + // Cette fonction est vouée à disparaitre lors du passage à Prisma, avec un simple "with" export const getMembersFromPermanences = async ( permanences: Permanence[], @@ -518,3 +689,38 @@ export const sendNotifications = async (notifications: Notification[]) => { } } }; + +export const sendConcurrentPermanenceNotifications = async (notifications: ConcurrentPermanenceNotification[]) => { + for (const notification of notifications) { + try { + const permanenceEmailData: ConcurrentPermanencesEmailData = { + permanences: notification.permanences.map((permanence) => ({ + name: permanence.name, + startAt: new Intl.DateTimeFormat('fr-FR', { + day: '2-digit', + month: 'long', + hour: '2-digit', + minute: '2-digit', + }).format(permanence.start_at), + endAt: new Intl.DateTimeFormat('fr-FR', { + day: '2-digit', + month: 'long', + hour: '2-digit', + minute: '2-digit', + }).format(permanence.end_at), + location: permanence.location, + })), + }; + + await sendEmail({ + from: email_from, + to: [notification.email], + subject: '[ATTENTION] Permanences concurrentes', + text: 'Certaines de vos permanences se chevauchent.', + html: generateEmailHtml('templateNotifyConcurrentPermanences', permanenceEmailData), + }); + } catch (error) { + console.error(`Erreur lors de l'envoi du conflit de permanences a ${notification.email}`, error); + } + } +}; diff --git a/backend/src/services/team.service.ts b/backend/src/services/team.service.ts index aba6cf5..b476237 100644 --- a/backend/src/services/team.service.ts +++ b/backend/src/services/team.service.ts @@ -298,7 +298,7 @@ export const teamDistribution = async () => { // Filtrer les utilisateurs en fonction de la spécialité const tcStudents: StudentRow[] = filteredStudents - .filter((student) => student.branch === 'TC') + .filter((student) => student.branch === 'TC' || student.branch === 'IA_BACH') .map((student) => ({ userId: student.userId, email: student.email, @@ -324,7 +324,13 @@ export const teamDistribution = async () => { })); const otherStudents: StudentRow[] = filteredStudents - .filter((student) => student.branch !== 'TC' && student.branch !== 'RI' && student.branch !== 'MM') + .filter( + (student) => + student.branch !== 'TC' && + student.branch !== 'RI' && + student.branch !== 'MM' && + student.branch !== 'IA_BACH', + ) .map((student) => ({ userId: student.userId, email: student.email, diff --git a/backend/src/types/email.d.ts b/backend/src/types/email.d.ts index 6688387..b49186e 100644 --- a/backend/src/types/email.d.ts +++ b/backend/src/types/email.d.ts @@ -25,6 +25,15 @@ export interface PermanenceEmailData extends TemplateData { permDescription: string; } +export interface ConcurrentPermanencesEmailData extends TemplateData { + permanences: { + name: string; + startAt: string; + endAt: string; + location: string; + }[]; +} + export interface TeamAssignmentEmailData extends TemplateData { teamName: string; teamSocialLink: string; diff --git a/backend/src/types/permanence.d.ts b/backend/src/types/permanence.d.ts index 190fbb6..4b2a057 100644 --- a/backend/src/types/permanence.d.ts +++ b/backend/src/types/permanence.d.ts @@ -11,8 +11,14 @@ export type CsvPermanence = { export type Notification = { permanence: Permanence; - members: LightUser[] -} + members: LightUser[]; +}; + +export type ConcurrentPermanenceNotification = { + userId: number; + email: string; + permanences: Permanence[]; +}; export type Permanence = { id: number; @@ -24,11 +30,11 @@ export type Permanence = { capacity: number; is_open: boolean; difficulty: number; -} +}; export type LightUser = { id: number; firstName: string; lastName: string; email: string; -} +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0de1a2b..46b0562 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import AdminRoute from './components/utils/adminroute'; import PrivateRoute from './components/utils/privateroute'; import ProtectedRoute from './components/utils/protectedroute'; import { OnboardingProvider } from './contexts/onboarding'; +import { PermanencesProvider } from './contexts/permanences'; import { UserProvider } from './contexts/user'; const AdminPageBanned = lazy(() => import('./pages/admin/adminBanned')); @@ -65,270 +66,272 @@ const App: React.FC = () => { - - {/* Public */} - } /> - } /> - } /> - } /> - } /> - } /> + + + {/* Public */} + } /> + } /> + } /> + } /> + } /> + } /> - {/* Utilisateurs connectés */} + {/* Utilisateurs connectés */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> - {/* Étudiant et Admin */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + {/* Étudiant et Admin */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> - {/* ResposCE et Admin */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + {/* ResposCE et Admin */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> - {/* ResposCE et Admin */} - - - - } - /> + {/* ResposCE et Admin */} + + + + } + /> - {/* Arbitre et Admin*/} - - - - } - /> - {/* Admin uniquement */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + {/* Arbitre et Admin*/} + + + + } + /> + {/* Admin uniquement */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> - {/* Fallback */} - } /> - + {/* Fallback */} + } /> + + diff --git a/frontend/src/components/Admin/adminUser.tsx b/frontend/src/components/Admin/adminUser.tsx index f0b632b..1bbf1fd 100644 --- a/frontend/src/components/Admin/adminUser.tsx +++ b/frontend/src/components/Admin/adminUser.tsx @@ -35,6 +35,7 @@ const branchOptions = [ { value: 'GM_APPR', label: 'Génie Mécanique en Apprentissage' }, { value: 'SN_APPR', label: 'Système Numérique en Apprentissage' }, { value: 'Branch', label: 'Branche' }, + { value: 'IA_BACH', label: 'Intelligence Artificielle en bachelor' }, { value: 'MM', label: 'Mécanique et Matériaux' }, { value: 'MA', label: 'Master' }, { value: 'RI', label: 'Ressources Internationales' }, diff --git a/frontend/src/components/challenge/challengeList.tsx b/frontend/src/components/challenge/challengeList.tsx index 938277f..9deba70 100644 --- a/frontend/src/components/challenge/challengeList.tsx +++ b/frontend/src/components/challenge/challengeList.tsx @@ -1,29 +1,29 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import Swal from "sweetalert2"; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import Swal from 'sweetalert2'; -import { type Challenge } from "../../interfaces/challenge.interface"; -import { type Faction } from "../../interfaces/faction.interface"; -import { getAllChallenges, getFactionsPoints } from "../../services/requests/challenge.service"; -import { checkChallengeStatus } from "../../services/requests/event.service"; -import { getAllFactionsUser } from "../../services/requests/faction.service"; -import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { type Challenge } from '../../interfaces/challenge.interface'; +import { type Faction } from '../../interfaces/faction.interface'; +import { getAllChallenges, getFactionsPoints } from '../../services/requests/challenge.service'; +import { checkChallengeStatus } from '../../services/requests/event.service'; +import { getAllFactionsUser } from '../../services/requests/faction.service'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; export const UserChallengeList = () => { const [availableChallenges, setAvailableChallenges] = useState([]); const [factions, setFactions] = useState([]); const [factionPoints, setFactionPoints] = useState<{ [key: number]: number }>({}); - const [selectedCategory, setSelectedCategory] = useState("Tous"); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + const [selectedCategory, setSelectedCategory] = useState('Tous'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); const [isChallOpen, setIsChallOpen] = useState(false); const [loading, setLoading] = useState(true); const fetchChallenges = useCallback(async () => { try { const challenges = await getAllChallenges(); - const challengesFiltered = challenges.filter((c: Challenge) => c.category !== "Free"); + const challengesFiltered = challenges.filter((c: Challenge) => c.category !== 'Free'); setAvailableChallenges(challengesFiltered); } catch (err) { - console.error("Erreur lors du chargement des challenges", err); + console.error('Erreur lors du chargement des challenges', err); } }, []); @@ -32,7 +32,7 @@ export const UserChallengeList = () => { const data = await getAllFactionsUser(); setFactions(data); } catch (err) { - console.error("Erreur lors du chargement des factions", err); + console.error('Erreur lors du chargement des factions', err); } }, []); @@ -44,11 +44,11 @@ export const UserChallengeList = () => { fetchedFactions.map(async (faction: Faction) => { const res = await getFactionsPoints(faction.factionId); points[faction.factionId] = Number(res); - }) + }), ); setFactionPoints(points); } catch (err) { - console.error("Erreur lors du chargement des points des factions", err); + console.error('Erreur lors du chargement des points des factions', err); } }, []); @@ -64,11 +64,11 @@ export const UserChallengeList = () => { const status = await checkChallengeStatus(); setIsChallOpen(status); } catch (error) { - console.error("Erreur lors de la récupération des données :", error); + console.error('Erreur lors de la récupération des données :', error); await Swal.fire({ - icon: "error", - title: "Oups...", - text: "Une erreur est survenue lors de la récupération des données.", + icon: 'error', + title: 'Oups...', + text: 'Une erreur est survenue lors de la récupération des données.', }); } finally { setLoading(false); @@ -78,20 +78,18 @@ export const UserChallengeList = () => { }, [fetchInitialData]); const dynamicCategories = useMemo(() => { - const uniqueCategories = new Set(availableChallenges.map(c => c.category)); - return ["Tous", ...Array.from(uniqueCategories)]; + const uniqueCategories = new Set(availableChallenges.map((c) => c.category)); + return ['Tous', ...Array.from(uniqueCategories)]; }, [availableChallenges]); const filteredAndSortedChallenges = useMemo(() => { let challenges = [...availableChallenges]; - if (selectedCategory !== "Tous") { - challenges = challenges.filter(c => c.category === selectedCategory); + if (selectedCategory !== 'Tous') { + challenges = challenges.filter((c) => c.category === selectedCategory); } - challenges.sort((a, b) => - sortOrder === "asc" ? a.points - b.points : b.points - a.points - ); + challenges.sort((a, b) => (sortOrder === 'asc' ? a.points - b.points : b.points - a.points)); return challenges; }, [availableChallenges, selectedCategory, sortOrder]); @@ -122,11 +120,36 @@ export const UserChallengeList = () => { + {/* Formulaire de validation de challenge */} + + + + Valider un Défi + + + {!isChallOpen ? ( +

+ 🚫 Ce formulaire n'est pas encore disponible. +

+ ) : ( +
+ +
+ )} +
+
+ {/* Challenges disponibles */} - 🏆 Challenges disponibles + 🏆 Défis disponibles @@ -137,7 +160,7 @@ export const UserChallengeList = () => { 🚫 Les challenges ne sont pas encore ouverts.

) : availableChallenges.length === 0 ? ( -

Aucun challenge disponible pour le moment.

+

Aucun défi disponible pour le moment.

) : ( <> {/* Filtres */} @@ -150,10 +173,11 @@ export const UserChallengeList = () => { id="categorySelect" value={selectedCategory} onChange={(e) => setSelectedCategory(e.target.value)} - className="border border-gray-300 rounded px-3 py-1" - > + className="border border-gray-300 rounded px-3 py-1"> {dynamicCategories.map((cat) => ( - + ))} @@ -165,9 +189,8 @@ export const UserChallengeList = () => { @@ -179,13 +202,16 @@ export const UserChallengeList = () => { {filteredAndSortedChallenges.map((challenge) => (
  • + className="bg-gray-100 py-4 px-6 rounded-xl border border-gray-300 shadow-sm hover:bg-gray-200 transition">

    {challenge.title}

    {challenge.description}

    -

    Catégorie : {challenge.category}

    -

    Points : {challenge.points}

    +

    + Catégorie : {challenge.category} +

    +

    + Points : {challenge.points} +

  • ))} diff --git a/frontend/src/components/navbar.tsx b/frontend/src/components/navbar.tsx index 9d5e8cf..79bd146 100644 --- a/frontend/src/components/navbar.tsx +++ b/frontend/src/components/navbar.tsx @@ -5,6 +5,7 @@ import { Fragment, useEffect, useState } from 'react'; import { NavLink, useLocation, useSearchParams } from 'react-router-dom'; import { useOnboarding } from '../contexts/onboarding'; +import { usePermanences } from '../contexts/permanences'; import { decodeToken, getToken } from '../services/requests/auth.service'; import { Button } from './ui/button'; @@ -32,6 +33,7 @@ export const Navbar = () => { const [hasContactInformation, setHasContactInformation] = useState(false); const [needsVssForm, setNeedsVssForm] = useState(false); const { status: onboardingStatus, loading: onboardingLoading } = useOnboarding(); + const { concurrentPermanences, loading: permanencesLoading } = usePermanences(); const [, setSearchParams] = useSearchParams(); const handleLogout = () => { @@ -52,7 +54,7 @@ export const Navbar = () => { // OnboardingProvider refreshes on 'user-onboarding-updated' event globally. // No local event listeners required here. return undefined; - }, [isAuthenticated, pathname]); + }, [isAuthenticated, onboardingLoading, onboardingStatus, pathname]); const navItems: NavItem[] = [ { label: 'Home', to: '/home', icon: HomeIcon }, @@ -247,6 +249,19 @@ export const Navbar = () => { )} + + {isAuthenticated && !permanencesLoading && concurrentPermanences && ( +
    +

    + ATTENTION : certaines de tes permanences se chevauchent. +

    + + + +
    + )} ); }; diff --git a/frontend/src/components/permanence/concurrentPerms.tsx b/frontend/src/components/permanence/concurrentPerms.tsx new file mode 100644 index 0000000..11e3312 --- /dev/null +++ b/frontend/src/components/permanence/concurrentPerms.tsx @@ -0,0 +1,44 @@ +import { format } from 'date-fns'; +import { fr } from 'date-fns/locale'; + +import type { ConcurrentPermanences } from '../../interfaces/permanence.interface'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; + +interface ConcurrentPermanencesProps { + permanences: ConcurrentPermanences['permanences']; +} + +export const ConcurrentPermanencesCard: React.FC = ({ permanences }) => { + return ( + + + Permanences concurrentes +

    Ces permanences se déroulent sur des créneaux qui se chevauchent.

    +
    + +
    + {permanences.map((permanence) => ( + + + {permanence.name} + + +

    📍 {permanence.location}

    +

    + 🕒 {format(new Date(permanence.start_at), "EEEE dd MMMM 'à' HH:mm", { locale: fr })}{' '} + - {format(new Date(permanence.end_at), 'HH:mm', { locale: fr })} +

    +
    +
    + ))} +
    +

    + Après un shotgun, vous avez jusqu'au lendemain midi pour + supprimer les doublons, sinon des permanences seront{' '} + retirées de manière aléatoire pour éliminer les conflits, + et les permanences libérées seront à nouveau shotgun le lendemain. +

    +
    +
    + ); +}; diff --git a/frontend/src/components/profil/profilForm.tsx b/frontend/src/components/profil/profilForm.tsx index 1209b90..2af7538 100644 --- a/frontend/src/components/profil/profilForm.tsx +++ b/frontend/src/components/profil/profilForm.tsx @@ -20,6 +20,7 @@ const branchOptions = [ { value: 'GM_APPR', label: 'Génie Mécanique en Apprentissage' }, { value: 'SN_APPR', label: 'Systeme Numérique en Apprentissage' }, { value: 'Branch', label: 'Branche' }, + { value: 'IA_BACH', label: 'Intelligence Artificielle en bachelor' }, { value: 'MM', label: 'Mécanique et Matériaux' }, { value: 'Master', label: 'Master' }, { value: 'RI', label: 'Ressources International' }, diff --git a/frontend/src/components/tent/tentSection.tsx b/frontend/src/components/tent/tentSection.tsx index 1e1d576..a641ab9 100644 --- a/frontend/src/components/tent/tentSection.tsx +++ b/frontend/src/components/tent/tentSection.tsx @@ -59,7 +59,7 @@ export const TentPublic = () => { fetchUsers(); fetchTent(); fetchWEIStatus(); - }, [roles]); + }, []); useEffect(() => { if (!roles.includes('Nouveau')) return; diff --git a/frontend/src/contexts/permanences.tsx b/frontend/src/contexts/permanences.tsx new file mode 100644 index 0000000..997c146 --- /dev/null +++ b/frontend/src/contexts/permanences.tsx @@ -0,0 +1,95 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; + +import type { ConcurrentPermanences } from '../interfaces/permanence.interface'; +import { getConcurrentPermanencesStatus } from '../services/requests/permanence.service'; +import { useUser } from './user'; + +type PermanencesContextType = { + concurrentPermanences: boolean; + concurrentPermanencesList: ConcurrentPermanences['permanences']; + loading: boolean; + refreshPermanences: () => Promise; +}; + +const PermanencesContext = createContext(undefined); + +let cachedPermanences: ConcurrentPermanences | null = null; + +export const usePermanences = (): PermanencesContextType => { + const ctx = useContext(PermanencesContext); + if (!ctx) throw new Error('usePermanences must be used within a PermanencesProvider'); + return ctx; +}; + +export const PermanencesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { user, loading: userLoading } = useUser(); + const [permanences, setPermanences] = useState(cachedPermanences); + const [loading, setLoading] = useState(() => cachedPermanences === null && userLoading); + + const fetchStatus = async () => { + if (!user || user.permission !== 'Student') { + setPermanences(null); + setLoading(false); + return; + } + + setLoading(true); + try { + const s = await getConcurrentPermanencesStatus(); + setPermanences(s ?? null); + cachedPermanences = s ?? null; + } catch { + setPermanences(null); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (userLoading) { + setLoading(true); + return; + } + + if (!user || user.permission !== 'Student') { + cachedPermanences = null; + setPermanences(null); + setLoading(false); + return; + } + + if (!cachedPermanences) void fetchStatus(); + + const handler = () => { + void fetchStatus(); + }; + + window.addEventListener('user-onboarding-updated', handler); + return () => window.removeEventListener('user-onboarding-updated', handler); + }, [user, userLoading]); + + const refreshPermanences = async () => { + if (!user || user.permission !== 'Student') { + cachedPermanences = null; + setPermanences(null); + return; + } + + cachedPermanences = null; + await fetchStatus(); + }; + + return ( + + {children} + + ); +}; + +export default PermanencesProvider; diff --git a/frontend/src/interfaces/permanence.interface.ts b/frontend/src/interfaces/permanence.interface.ts index 577beb4..7baa3a8 100644 --- a/frontend/src/interfaces/permanence.interface.ts +++ b/frontend/src/interfaces/permanence.interface.ts @@ -1,4 +1,4 @@ -import { type User } from "./user.interface"; +import { type User } from './user.interface'; export interface Permanence { id: number; @@ -18,4 +18,9 @@ export interface Permanence { export interface PermanenceActionsProps { permanences: Permanence[]; onRefresh: () => void; -}; +} + +export interface ConcurrentPermanences { + concurrentPermanences: boolean; + permanences: Pick[]; +} diff --git a/frontend/src/pages/perm/permMy.tsx b/frontend/src/pages/perm/permMy.tsx index 4665ce3..7db2a00 100644 --- a/frontend/src/pages/perm/permMy.tsx +++ b/frontend/src/pages/perm/permMy.tsx @@ -3,12 +3,15 @@ import Swal from 'sweetalert2'; import EmergencyModal from '../../components/home/emergencyModal'; import { Navbar } from '../../components/navbar'; +import { ConcurrentPermanencesCard } from '../../components/permanence/concurrentPerms'; import { MyPermanencesList } from '../../components/permanence/permUser'; +import { usePermanences } from '../../contexts/permanences'; import { type Permanence } from '../../interfaces/permanence.interface'; import { cancelPermanence, getMyPermanences } from '../../services/requests/permanence.service'; const PermanencesPageMy: React.FC = () => { const [myPermanences, setMyPermanences] = useState([]); + const { concurrentPermanences, concurrentPermanencesList, refreshPermanences } = usePermanences(); useEffect(() => { fetchMyPermanences(); @@ -57,6 +60,7 @@ const PermanencesPageMy: React.FC = () => { const response = await cancelPermanence(permId); Swal.fire('Succès ✅', response.message, 'success'); fetchMyPermanences(); + await refreshPermanences(); } catch (err) { console.error("Erreur lors de l'annulation", err); Swal.fire('Erreur', 'Impossible de vous désinscrire.', 'error'); @@ -69,6 +73,7 @@ const PermanencesPageMy: React.FC = () => {
    + {concurrentPermanences && }
    diff --git a/frontend/src/services/requests/permanence.service.ts b/frontend/src/services/requests/permanence.service.ts index e470f42..283f22b 100644 --- a/frontend/src/services/requests/permanence.service.ts +++ b/frontend/src/services/requests/permanence.service.ts @@ -1,14 +1,15 @@ -import api from "../api"; // ton instance axios ou une autre bibliothèque pour les requêtes HTTP +import { type ConcurrentPermanences } from '../../interfaces/permanence.interface'; +import api from '../api'; // ton instance axios ou une autre bibliothèque pour les requêtes HTTP // Fonction pour récupérer toutes les permanences ouvertes export const getOpenPermanences = async () => { - const response = await api.get("/permanence/user/permanences"); + const response = await api.get('/permanence/user/permanences'); return response.data.data; // La réponse est de type PermanenceResponse }; // Fonction pour récupérer toutes les permanences (admin) export const getAllPermanences = async () => { - const response = await api.get("/permanence/admin/permanences"); + const response = await api.get('/permanence/admin/permanences'); return response.data; // La réponse est de type PermanenceResponse }; @@ -29,7 +30,7 @@ export const createPermanence = async (permanenceData: { difficulty: number; respoId: number | null; }) => { - const response = await api.post("/permanence/admin/permanence", { + const response = await api.post('/permanence/admin/permanence', { name: permanenceData.name, description: permanenceData.description, location: permanenceData.location, @@ -37,21 +38,19 @@ export const createPermanence = async (permanenceData: { end_at: permanenceData.end_at, capacity: permanenceData.capacity, difficulty: permanenceData.difficulty, - respoId: permanenceData.respoId + respoId: permanenceData.respoId, }); return response.data; // La réponse est de type Permanent - }; // Fonction pour ouvrir une permanence (admin) export const openPermanence = async (permId: number) => { - const response = await api.post("/permanence/admin/open", { + const response = await api.post('/permanence/admin/open', { permId, }); return response.data; // La réponse est de type Permanent }; - // Fonction pour fermer une permanence (admin) export const closePermanence = async (permId: number) => { const response = await api.post(`/permanence/admin/close`, { permId }); @@ -68,17 +67,20 @@ export const getMyPermanences = async () => { return response.data.data; }; -export const updatePermanence = async (permId: number, permanenceData: { - name: string; - description: string; - location: string; - start_at: string | null; - end_at: string | null; - capacity: number; - difficulty: number; - respoId: number | null; -}) => { - const response = await api.post("/permanence/admin/updatepermanence", { +export const updatePermanence = async ( + permId: number, + permanenceData: { + name: string; + description: string; + location: string; + start_at: string | null; + end_at: string | null; + capacity: number; + difficulty: number; + respoId: number | null; + }, +) => { + const response = await api.post('/permanence/admin/updatepermanence', { permId, name: permanenceData.name, description: permanenceData.description, @@ -87,10 +89,9 @@ export const updatePermanence = async (permId: number, permanenceData: { end_at: permanenceData.end_at, capacity: permanenceData.capacity, difficulty: permanenceData.difficulty, - respoId: permanenceData.respoId + respoId: permanenceData.respoId, }); return response.data; // La réponse est de type Permanent - }; export const getUsersByPermanence = async (permId: number) => { @@ -106,7 +107,7 @@ export const addUserToPermanence = async (permId: number, userId: number) => { export const removeUserFromPermanence = async (permId: number, userId: number) => { const response = await api.post(`/permanence/admin/remove`, { userId, permId }); return response.data; -} +}; export const deletePermanence = async (permId: number) => { const response = await api.delete(`/permanence/admin/permanence`, { params: { permId } }); @@ -116,27 +117,27 @@ export const deletePermanence = async (permId: number) => { export const importPermanenceCSV = async (formData: FormData) => { const response = await api.post(`/permanence/admin/importpermanences`, formData, { headers: { - "Content-Type": "multipart/form-data", + 'Content-Type': 'multipart/form-data', }, }); return response.data; -} +}; export const isUserRespo = async (userId: number) => { const response = await api.get(`/permanence/user/isrespo`, { params: { userId } }); return response.data; -} +}; export const respoDetails = async () => { const response = await api.get(`/permanence/respo/respodetails`); return response.data; -} +}; export const claimedMember = async (userId: number, permId: number, claimed: boolean) => { const response = await api.post(`/permanence/respo/claimedmember`, { userId, permId, - claimed + claimed, }); return response.data; }; @@ -145,7 +146,13 @@ export const claimedMemberAdmin = async (userId: number, permId: number, claimed const response = await api.post(`/permanence/admin/claimedmember`, { userId, permId, - claimed + claimed, }); return response.data; }; + +export const getConcurrentPermanencesStatus = async () => { + const response = await api.get('/permanence/user/concurrent/status'); + const status: ConcurrentPermanences = response.data.data; + return status; +};