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
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 !
+
+
+ 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) => (
- {cat}
+
+ {cat}
+
))}
@@ -165,9 +189,8 @@ export const UserChallengeList = () => {
setSortOrder(e.target.value as "asc" | "desc")}
- className="border border-gray-300 rounded px-3 py-1"
- >
+ onChange={(e) => setSortOrder(e.target.value as 'asc' | 'desc')}
+ className="border border-gray-300 rounded px-3 py-1">
Croissant
Décroissant
@@ -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.
+
+
+
+ Voir mes permanences
+
+
+
+ )}
);
};
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;
+};