From 961fcfe646c6f9cad285d75ce4f45a98580fd4a5 Mon Sep 17 00:00:00 2001 From: Antony David Date: Fri, 19 Jul 2024 10:56:10 +0200 Subject: [PATCH] feat: Add election system when closing assembly (#259) --- .../dashboard/assemblies/details/assembly.tsx | 266 ++++++++++++++---- .../dashboard/assemblies/details/page.tsx | 3 +- .../(dashboard)/dashboard/assemblies/utils.ts | 8 +- apps/admin/app/lib/utils.ts | 5 +- .../activities/details/Occurences.tsx | 2 +- .../activities/details/ValidateUser.tsx | 2 +- apps/api/src/handlers/assemblies.ts | 18 +- apps/api/src/handlers/users.ts | 16 +- apps/api/src/routes/assemblies.ts | 5 +- 9 files changed, 253 insertions(+), 72 deletions(-) diff --git a/apps/admin/app/(dashboard)/dashboard/assemblies/details/assembly.tsx b/apps/admin/app/(dashboard)/dashboard/assemblies/details/assembly.tsx index dc5a8b4e..be874b80 100644 --- a/apps/admin/app/(dashboard)/dashboard/assemblies/details/assembly.tsx +++ b/apps/admin/app/(dashboard)/dashboard/assemblies/details/assembly.tsx @@ -1,5 +1,6 @@ 'use client'; +import { type Address, getOneAddress } from '@/app/(dashboard)/dashboard/addresses/utils'; import { type Assembly, addAttendee, @@ -9,7 +10,9 @@ import { } from '@/app/(dashboard)/dashboard/assemblies/utils'; import type { User } from '@/app/lib/type/User'; import { getAllMembersForAssembly } from '@/app/lib/utils'; +import { Badge } from '@ui/components/ui/badge'; import { Button } from '@ui/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@ui/components/ui/card'; import { Dialog, DialogContent, @@ -21,14 +24,14 @@ import { } from '@ui/components/ui/dialog'; import { Label } from '@ui/components/ui/label'; import Loading from '@ui/components/ui/loading'; +import { ScrollArea } from '@ui/components/ui/scroll-area'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ui/components/ui/select'; import { toast } from '@ui/components/ui/sonner'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ui/components/ui/table'; import { Textarea } from '@ui/components/ui/textarea'; import { BookOpenText, CircleArrowLeft, HomeIcon } from 'lucide-react'; import { useSearchParams } from 'next/navigation'; -import { type FormEvent, useEffect, useState } from 'react'; -import { type Address, getOneAddress } from '../../addresses/utils'; +import { type FormEvent, useEffect, useMemo, useState } from 'react'; export default function AssemblyDetail(): JSX.Element { const searchParams = useSearchParams(); @@ -44,6 +47,7 @@ export default function AssemblyDetail(): JSX.Element { const [qrCode, setQrCode] = useState(''); const [started, setStarted] = useState(false); const [location, setLocation] = useState
(null); + const [roles, setRoles] = useState<{ id_role: number; id_user: number }[]>([]); useEffect(() => { const fetchAssembly = async () => { @@ -54,8 +58,7 @@ export default function AssemblyDetail(): JSX.Element { setAttendees(data.attendees?.length ?? 0); setIsClosed(data.closed); - const attendeesArray = (data?.attendees ?? []).map((attendee) => attendee.id); - const membersData = await getAllMembersForAssembly(attendeesArray); + const membersData = await getAllMembersForAssembly(); setMembers(membersData.data); setStarted(new Date().getTime() > new Date(data.date).getTime()); @@ -86,6 +89,7 @@ export default function AssemblyDetail(): JSX.Element { const data = await getAssembly(Number(idPoll)); setAttendees(data.attendees?.length ?? 0); setAssembly(data); + toast.success("Membre ajouté à l'assemblée"); } catch (_error) { toast.error("Erreur lors de l'ajout du membre"); } @@ -93,12 +97,20 @@ export default function AssemblyDetail(): JSX.Element { async function handleEndAssembly(event: React.FormEvent) { event.preventDefault(); - const formData = new FormData(event.currentTarget); - const lawsuit = formData.get('lawsuit') as string; - await closeAssembly(Number(idPoll), lawsuit); - setOpenCloseAssembly(false); - const data = await getAssembly(Number(idPoll)); - setAssembly(data); + try { + const formData = new FormData(event.currentTarget); + const lawsuit = formData.get('lawsuit') as string; + await closeAssembly(Number(idPoll), lawsuit, roles); + setOpenCloseAssembly(false); + const data = await getAssembly(Number(idPoll)); + setAssembly(data); + const membersData = await getAllMembersForAssembly(); + setMembers(membersData.data); + setIsClosed(true); + toast.success("L'assemblée a bien été clôturée"); + } catch (_error) { + toast.error("Erreur lors de la clôture de l'assemblée"); + } } async function requestQrCode() { @@ -111,6 +123,14 @@ export default function AssemblyDetail(): JSX.Element { setOpenQrCode(true); } + const currentRoles: { id_role: number; id_user: number }[] = members?.reduce( + (acc: { id_role: number; id_user: number }[], member) => { + const roles = member.roles.map((role) => ({ id_role: role.id, id_user: member.id })); + return acc.concat(roles); + }, + [], + ); + if (loading) { return ; } @@ -163,50 +183,66 @@ export default function AssemblyDetail(): JSX.Element { )} + {started && ( + + )} {started && ( <> -
-

Membres de l'assemblée ({attendees})

- {!isClosed && ( -
- - -
- )} -
-
- - - - Nom et Prénom - Email - - - - {attendees === 0 && ( - - Aucun membre n'a encore été ajouté à cette assemblée - + + + + Membres de l'assemblée ({attendees}) + {!isClosed && ( +
+ + +
)} - {assembly?.attendees?.map((member) => ( - - {`${member.first_name} ${member.last_name}`} - {member.email} - - )) ?? []} -
-
-
+ + + + + + + + Nom et Prénom + Email + + + + {attendees === 0 && ( + + Aucun membre n'a encore été ajouté à cette assemblée + + )} + {assembly?.attendees?.map((member) => ( + + {`${member.first_name} ${member.last_name}`} + {member.email} + + )) ?? []} + +
+
+
+ )} @@ -214,16 +250,20 @@ export default function AssemblyDetail(): JSX.Element { } function AddAttendeeDialog({ + attendees, openAddAttendee, setOpenAddAttendee, members, handleAddAttendee, }: { + attendees: Assembly['attendees']; openAddAttendee: boolean; setOpenAddAttendee: (value: boolean) => void; members: User[]; handleAddAttendee: (event: FormEvent) => void; }) { + members = members?.filter((member) => !attendees?.some((attendee) => attendee.id === member.id)) ?? []; + return ( @@ -297,7 +337,7 @@ function CloseAssemblyDialog({ /> - + @@ -337,3 +377,125 @@ function QrCodeDialog({ ); } + +enum ElectionRole { + Président = 6, + Vice_président = 9, + Secrétaire = 7, + Trésorier = 8, + Chargé_de_communication = 11, + Chef_de_projet = 12, +} + +interface RoleSelection { + id_role: number; + id_user: number; +} + +interface RoleSelectionComponentProps { + users: User[]; + roles: RoleSelection[]; + setRoles: React.Dispatch>; + currentRoles?: RoleSelection[]; + isClosed?: boolean; +} + +function RoleSelectionComponent({ + users, + roles, + setRoles, + currentRoles = [], + isClosed = false, +}: RoleSelectionComponentProps) { + const handleRoleChange = (roleId: number, userId: number) => { + setRoles((prevRoles) => { + const newRoles = prevRoles.filter((role) => role.id_role !== roleId); + if (userId !== 0 && !newRoles.some((role) => role.id_user === userId)) { + newRoles.push({ id_role: roleId, id_user: userId }); + } + return newRoles; + }); + }; + + const clearAllSelections = () => { + setRoles([]); + }; + + const getUserName = (userId: number) => { + const user = users.find((u) => u.id === userId); + return user ? `${user.first_name} ${user.last_name}` : 'Aucun'; + }; + + const memoizedUsers = useMemo(() => { + return [ + { id: 0, first_name: 'Pas de changement', last_name: '' }, + ...users.sort((a, b) => a.last_name.localeCompare(b.last_name)), + ]; + }, [users]); + + return ( + + + + Election des membres du bureau + + + + + + + + + + + + + + + {Object.entries(ElectionRole) + .filter(([roleName, roleId]) => typeof roleName === 'string' && typeof roleId === 'number') + .map(([roleName, roleId]) => { + const currentRoleHolder = currentRoles.find((r) => r.id_role === roleId); + const selectedUser = roles.find((r) => r.id_role === roleId)?.id_user; + return ( + + + + + + ); + })} + +
RôleMembre actuelNouveau membre
+ + + {currentRoleHolder ? ( + {getUserName(currentRoleHolder.id_user)} + ) : ( + Aucun + )} + + +
+
+
+
+ ); +} diff --git a/apps/admin/app/(dashboard)/dashboard/assemblies/details/page.tsx b/apps/admin/app/(dashboard)/dashboard/assemblies/details/page.tsx index 0cac42a9..872a182b 100644 --- a/apps/admin/app/(dashboard)/dashboard/assemblies/details/page.tsx +++ b/apps/admin/app/(dashboard)/dashboard/assemblies/details/page.tsx @@ -1,3 +1,4 @@ +import Loading from '@ui/components/ui/loading'; import dynamic from 'next/dynamic'; import { Suspense } from 'react'; @@ -6,7 +7,7 @@ const DynamicAssemblyDetail = dynamic(() => import('./assembly'), { ssr: false } export default function page(): JSX.Element { return (
- Chargement...}> + }>
diff --git a/apps/admin/app/(dashboard)/dashboard/assemblies/utils.ts b/apps/admin/app/(dashboard)/dashboard/assemblies/utils.ts index e95dba02..4188ff1d 100644 --- a/apps/admin/app/(dashboard)/dashboard/assemblies/utils.ts +++ b/apps/admin/app/(dashboard)/dashboard/assemblies/utils.ts @@ -109,7 +109,11 @@ export async function addAttendee(id_assembly: number, id_user: number): Promise } } -export async function closeAssembly(id: number, lawsuit: string): Promise { +export async function closeAssembly( + id: number, + lawsuit: string, + roles?: { id_role: number; id_user: number }[], +): Promise { const urlApi = process.env.ATHLONIX_API_URL; const token = cookies().get('access_token')?.value; await fetch(`${urlApi}/assemblies/${id}/close`, { @@ -118,7 +122,7 @@ export async function closeAssembly(id: number, lawsuit: string): Promise 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ lawsuit }), + body: JSON.stringify({ lawsuit, roles }), }); } diff --git a/apps/admin/app/lib/utils.ts b/apps/admin/app/lib/utils.ts index 57f9d187..18ba7425 100644 --- a/apps/admin/app/lib/utils.ts +++ b/apps/admin/app/lib/utils.ts @@ -34,7 +34,7 @@ export async function LogoutUser(): Promise { .catch((error: Error) => console.error(error)); } -export async function getAllMembersForAssembly(attendees: number[]): Promise<{ data: User[]; count: number }> { +export async function getAllMembersForAssembly(): Promise<{ data: User[]; count: number }> { const urlApi = process.env.ATHLONIX_API_URL; const token = cookies().get('access_token')?.value; const response = await fetch(`${urlApi}/users?all=true&role=MEMBER`, { @@ -47,8 +47,7 @@ export async function getAllMembersForAssembly(attendees: number[]): Promise<{ d } const data = (await response.json()) as { data: User[]; count: number }; const members = data.data.filter( - (member: User) => - member.date_validity && new Date(member.date_validity) > new Date() && !attendees.includes(member.id), + (member: User) => member.date_validity && new Date(member.date_validity) > new Date(), ); return { data: members, diff --git a/apps/admin/app/ui/dashboard/activities/details/Occurences.tsx b/apps/admin/app/ui/dashboard/activities/details/Occurences.tsx index 7d73d616..7fbd1d65 100644 --- a/apps/admin/app/ui/dashboard/activities/details/Occurences.tsx +++ b/apps/admin/app/ui/dashboard/activities/details/Occurences.tsx @@ -1,6 +1,6 @@ import type { Activity, Occurence, User } from '@/app/lib/type/Activities'; import ValidateUser from '@/app/ui/dashboard/activities/details/ValidateUser'; -import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar'; +import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'; import { Badge } from '@repo/ui/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@repo/ui/components/ui/card'; import { ScrollArea } from '@repo/ui/components/ui/scroll-area'; diff --git a/apps/admin/app/ui/dashboard/activities/details/ValidateUser.tsx b/apps/admin/app/ui/dashboard/activities/details/ValidateUser.tsx index d519711b..f45e5026 100644 --- a/apps/admin/app/ui/dashboard/activities/details/ValidateUser.tsx +++ b/apps/admin/app/ui/dashboard/activities/details/ValidateUser.tsx @@ -2,7 +2,7 @@ import type { User } from '@/app/lib/type/Activities'; import { validateUsers } from '@/app/lib/utils/activities'; import { Button } from '@ui/components/ui/button'; import { toast } from '@ui/components/ui/sonner'; -import { Check, PlusCircleIcon } from 'lucide-react'; +import { PlusCircleIcon } from 'lucide-react'; import type { Dispatch, SetStateAction } from 'react'; function ValidateUser({ diff --git a/apps/api/src/handlers/assemblies.ts b/apps/api/src/handlers/assemblies.ts index 12c5bbea..9fd9a527 100644 --- a/apps/api/src/handlers/assemblies.ts +++ b/apps/api/src/handlers/assemblies.ts @@ -225,11 +225,10 @@ assemblies.openapi(updateAssembly, async (c) => { assemblies.openapi(closeAssembly, async (c) => { const user = c.get('user'); - const roles = user.roles; - await checkRole(roles, false); + await checkRole(user.roles, false); const { id } = c.req.valid('param'); - const { lawsuit } = c.req.valid('json'); + const { lawsuit, roles } = c.req.valid('json'); const { data: assembly, error: assemblyError } = await supabase.from('ASSEMBLIES').select('id').eq('id', id).single(); @@ -243,6 +242,19 @@ assemblies.openapi(closeAssembly, async (c) => { return c.json({ error: 'Failed to close assembly' }, 500); } + if (roles) { + for (const role of roles) { + await supabase.from('USERS_ROLES').delete().eq('id_role', role.id_role); + const { error: updateRole } = await supabase + .from('USERS_ROLES') + .insert({ id_user: role.id_user, id_role: role.id_role }); + + if (updateRole) { + return c.json({ error: 'Failed to update roles' }, 500); + } + } + } + return c.json({ message: 'Assembly closed' }, 200); }); diff --git a/apps/api/src/handlers/users.ts b/apps/api/src/handlers/users.ts index 2cb4f531..e2bccb35 100644 --- a/apps/api/src/handlers/users.ts +++ b/apps/api/src/handlers/users.ts @@ -33,7 +33,7 @@ users.openapi(getAllUsers, async (c) => { const query = supabase .from('USERS') - .select('*, roles:ROLES!inner(id, name)', { count: 'exact' }) + .select('*, roles:ROLES(id, name)', { count: 'exact' }) .filter('deleted_at', 'is', null) .order('created_at', { ascending: true }); @@ -46,19 +46,19 @@ users.openapi(getAllUsers, async (c) => { query.range(from, to); } + let { data, error, count } = await query; + + if (error) { + return c.json({ error: error.message }, 500); + } + if (role) { const { data: roleFound } = await supabase.from('ROLES').select('id').eq('name', role.toUpperCase()).single(); if (!roleFound) { return c.json({ error: 'Role not found in query' }, 404); } - query.eq('ROLES.id', roleFound.id); - } - - const { data, error, count } = await query; - - if (error) { - return c.json({ error: error.message }, 500); + data = data?.filter((user) => user.roles.some((userRole) => userRole.id === roleFound.id)) || []; } const responseData = { diff --git a/apps/api/src/routes/assemblies.ts b/apps/api/src/routes/assemblies.ts index 68643ee7..19d53fca 100644 --- a/apps/api/src/routes/assemblies.ts +++ b/apps/api/src/routes/assemblies.ts @@ -141,7 +141,10 @@ export const closeAssembly = createRoute({ body: { content: { 'application/json': { - schema: z.object({ lawsuit: z.string() }), + schema: z.object({ + lawsuit: z.string().min(20), + roles: z.array(z.object({ id_user: z.number().positive(), id_role: z.number().positive() })).optional(), + }), }, }, },