From 3eed79ca93eefdf4700d412d10c878af53576968 Mon Sep 17 00:00:00 2001 From: Pierre Date: Wed, 21 Jan 2026 18:13:35 +0100 Subject: [PATCH] Added Chat Page --- .gitignore | 3 + app/api/auth/me/route.ts | 16 + app/api/conversations/[id]/events/route.ts | 149 +++++ app/api/conversations/[id]/messages/route.ts | 220 +++++++ .../conversations/[id]/participants/route.ts | 171 +++++ app/api/conversations/[id]/read/route.ts | 94 +++ app/api/conversations/[id]/route.ts | 163 +++++ app/api/conversations/[id]/typing/route.ts | 113 ++++ app/api/conversations/route.ts | 236 +++++++ app/api/messages/[id]/route.ts | 142 +++++ app/api/trajets/[id]/archive/route.ts | 97 +++ app/api/trajets/archives/route.ts | 53 ++ app/api/trajets/route.ts | 4 +- app/api/users/route.ts | 42 ++ app/dashboard/archives/page.tsx | 21 + app/dashboard/messagerie/page.tsx | 20 + app/globals.css | 15 + app/layout.tsx | 9 +- components/ArchivesTrajets.tsx | 258 ++++++++ components/CalendrierTrajets.tsx | 12 +- components/ChatWindow.tsx | 589 ++++++++++++++++++ components/ConfirmModal.tsx | 70 +++ components/DashboardLayout.tsx | 178 +++++- components/GroupSettingsModal.tsx | 300 +++++++++ components/MessageNotifications.tsx | 169 +++++ components/Messagerie.tsx | 224 +++++++ components/NewConversationModal.tsx | 239 +++++++ components/NotificationProvider.tsx | 65 ++ components/NotificationToast.tsx | 73 +++ components/TrajetDetailModal.tsx | 86 ++- components/TrajetForm.tsx | 10 +- components/ValidationModal.tsx | 15 +- package-lock.json | 46 +- package.json | 3 +- prisma/dev.db | Bin 49152 -> 114688 bytes prisma/schema.prisma | 59 +- scripts/setup.ts | 66 +- 37 files changed, 3966 insertions(+), 64 deletions(-) create mode 100644 app/api/auth/me/route.ts create mode 100644 app/api/conversations/[id]/events/route.ts create mode 100644 app/api/conversations/[id]/messages/route.ts create mode 100644 app/api/conversations/[id]/participants/route.ts create mode 100644 app/api/conversations/[id]/read/route.ts create mode 100644 app/api/conversations/[id]/route.ts create mode 100644 app/api/conversations/[id]/typing/route.ts create mode 100644 app/api/conversations/route.ts create mode 100644 app/api/messages/[id]/route.ts create mode 100644 app/api/trajets/[id]/archive/route.ts create mode 100644 app/api/trajets/archives/route.ts create mode 100644 app/api/users/route.ts create mode 100644 app/dashboard/archives/page.tsx create mode 100644 app/dashboard/messagerie/page.tsx create mode 100644 components/ArchivesTrajets.tsx create mode 100644 components/ChatWindow.tsx create mode 100644 components/ConfirmModal.tsx create mode 100644 components/GroupSettingsModal.tsx create mode 100644 components/MessageNotifications.tsx create mode 100644 components/Messagerie.tsx create mode 100644 components/NewConversationModal.tsx create mode 100644 components/NotificationProvider.tsx create mode 100644 components/NotificationToast.tsx diff --git a/.gitignore b/.gitignore index 55acc03..2e51005 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ next-env.d.ts # prisma /prisma/migrations + +# uploads +/public/uploads diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..b489e3b --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; + +// GET - Récupérer l'utilisateur actuel +export async function GET() { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + return NextResponse.json(user); + } catch (error) { + console.error('Erreur lors de la récupération de l\'utilisateur:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} diff --git a/app/api/conversations/[id]/events/route.ts b/app/api/conversations/[id]/events/route.ts new file mode 100644 index 0000000..b1a4aaa --- /dev/null +++ b/app/api/conversations/[id]/events/route.ts @@ -0,0 +1,149 @@ +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getCurrentUser } from '@/lib/auth'; + +// Server-Sent Events pour les notifications en temps réel +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + const user = await getCurrentUser(); + if (!user) { + return new Response('Non autorisé', { status: 401 }); + } + + const conversation = await prisma.conversation.findUnique({ + where: { id: params.id }, + include: { + participants: true, + }, + }); + + if (!conversation) { + return new Response('Conversation non trouvée', { status: 404 }); + } + + // Vérifier que l'utilisateur est participant + const isParticipant = conversation.participants.some((p) => p.userId === user.id); + if (!isParticipant) { + return new Response('Accès non autorisé', { status: 403 }); + } + + // Créer un stream SSE + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + let isClosed = false; + + // Fonction pour envoyer un événement + const sendEvent = (data: any) => { + if (isClosed) return; + try { + const message = `data: ${JSON.stringify(data)}\n\n`; + controller.enqueue(encoder.encode(message)); + } catch (error) { + // Le controller est fermé, arrêter le polling + isClosed = true; + clearInterval(pollInterval); + } + }; + + // Envoyer un événement de connexion + sendEvent({ type: 'connected', conversationId: params.id }); + + // Polling pour vérifier les nouveaux messages + let lastMessageId: string | null = null; + const pollInterval = setInterval(async () => { + if (isClosed) { + clearInterval(pollInterval); + return; + } + + try { + const lastMessage = await prisma.message.findFirst({ + where: { + conversationId: params.id, + ...(lastMessageId ? { id: { gt: lastMessageId } } : {}), + }, + orderBy: { + createdAt: 'desc', + }, + include: { + sender: { + select: { + id: true, + email: true, + name: true, + }, + }, + files: true, + }, + }); + + if (lastMessage) { + lastMessageId = lastMessage.id; + sendEvent({ + type: 'new_message', + message: lastMessage, + }); + } + + // Vérifier les mises à jour de conversation + const updatedConversation = await prisma.conversation.findUnique({ + where: { id: params.id }, + include: { + participants: { + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }, + }, + }); + + if (updatedConversation) { + sendEvent({ + type: 'conversation_updated', + conversation: updatedConversation, + }); + } + } catch (error) { + // Si c'est une erreur de controller fermé, arrêter le polling + if (error instanceof Error && error.message.includes('closed')) { + isClosed = true; + clearInterval(pollInterval); + } else { + console.error('Erreur lors du polling:', error); + } + } + }, 1000); // Poll toutes les secondes + + // Nettoyer lors de la fermeture + const cleanup = () => { + isClosed = true; + clearInterval(pollInterval); + try { + controller.close(); + } catch (e) { + // Ignorer les erreurs de fermeture + } + }; + + // Gérer la fermeture de la connexion + request.signal?.addEventListener('abort', cleanup); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); +} diff --git a/app/api/conversations/[id]/messages/route.ts b/app/api/conversations/[id]/messages/route.ts new file mode 100644 index 0000000..83c39e0 --- /dev/null +++ b/app/api/conversations/[id]/messages/route.ts @@ -0,0 +1,220 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getCurrentUser } from '@/lib/auth'; +import { writeFile } from 'fs/promises'; +import { join } from 'path'; +import { existsSync, mkdirSync } from 'fs'; + +// GET - Récupérer les messages d'une conversation +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const searchParams = request.nextUrl.searchParams; + const limit = parseInt(searchParams.get('limit') || '50'); + const cursor = searchParams.get('cursor'); + + const conversation = await prisma.conversation.findUnique({ + where: { id: params.id }, + include: { + participants: true, + }, + }); + + if (!conversation) { + return NextResponse.json({ error: 'Conversation non trouvée' }, { status: 404 }); + } + + // Vérifier que l'utilisateur est participant + const isParticipant = conversation.participants.some((p) => p.userId === user.id); + if (!isParticipant) { + return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 }); + } + + // Mettre à jour lastReadAt pour l'utilisateur + await prisma.conversationParticipant.updateMany({ + where: { + conversationId: params.id, + userId: user.id, + }, + data: { + lastReadAt: new Date(), + }, + }); + + const where: any = { + conversationId: params.id, + }; + + if (cursor) { + where.id = { + lt: cursor, + }; + } + + const messages = await prisma.message.findMany({ + where, + include: { + sender: { + select: { + id: true, + email: true, + name: true, + }, + }, + files: true, + }, + orderBy: { + createdAt: 'desc', + }, + take: limit, + }); + + return NextResponse.json({ + messages: messages.reverse(), // Inverser pour avoir les plus anciens en premier + hasMore: messages.length === limit, + cursor: messages.length > 0 ? messages[0].id : null, + }); + } catch (error) { + console.error('Erreur lors de la récupération des messages:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} + +// POST - Envoyer un message dans une conversation +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const conversation = await prisma.conversation.findUnique({ + where: { id: params.id }, + include: { + participants: true, + }, + }); + + if (!conversation) { + return NextResponse.json({ error: 'Conversation non trouvée' }, { status: 404 }); + } + + // Vérifier que l'utilisateur est participant + const isParticipant = conversation.participants.some((p) => p.userId === user.id); + if (!isParticipant) { + return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 }); + } + + const formData = await request.formData(); + const content = formData.get('content') as string | null; + const files = formData.getAll('files') as File[]; + + if (!content && (!files || files.length === 0)) { + return NextResponse.json( + { error: 'Le message doit contenir du texte ou au moins un fichier' }, + { status: 400 } + ); + } + + // Créer le message + const message = await prisma.message.create({ + data: { + conversationId: params.id, + senderId: user.id, + content: content || null, + }, + include: { + sender: { + select: { + id: true, + email: true, + name: true, + }, + }, + files: true, + }, + }); + + // Traiter les fichiers si présents + if (files && files.length > 0) { + const uploadDir = join(process.cwd(), 'public', 'uploads', 'messages'); + + // Créer le dossier s'il n'existe pas + if (!existsSync(uploadDir)) { + mkdirSync(uploadDir, { recursive: true }); + } + + const fileRecords = await Promise.all( + files.map(async (file) => { + const bytes = await file.arrayBuffer(); + const buffer = Buffer.from(bytes); + + // Générer un nom de fichier unique + const timestamp = Date.now(); + const randomStr = Math.random().toString(36).substring(2, 15); + const fileExtension = file.name.split('.').pop(); + const filename = `${timestamp}-${randomStr}.${fileExtension}`; + const filepath = join(uploadDir, filename); + + // Sauvegarder le fichier + await writeFile(filepath, buffer); + + // Créer l'enregistrement en base de données + return prisma.messageFile.create({ + data: { + messageId: message.id, + filename: file.name, + filepath: `/uploads/messages/${filename}`, + fileType: file.type, + fileSize: file.size, + }, + }); + }) + ); + + // Récupérer le message avec les fichiers + const messageWithFiles = await prisma.message.findUnique({ + where: { id: message.id }, + include: { + sender: { + select: { + id: true, + email: true, + name: true, + }, + }, + files: true, + }, + }); + + // Mettre à jour la date de mise à jour de la conversation + await prisma.conversation.update({ + where: { id: params.id }, + data: { updatedAt: new Date() }, + }); + + return NextResponse.json(messageWithFiles, { status: 201 }); + } + + // Mettre à jour la date de mise à jour de la conversation + await prisma.conversation.update({ + where: { id: params.id }, + data: { updatedAt: new Date() }, + }); + + return NextResponse.json(message, { status: 201 }); + } catch (error) { + console.error('Erreur lors de l\'envoi du message:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} diff --git a/app/api/conversations/[id]/participants/route.ts b/app/api/conversations/[id]/participants/route.ts new file mode 100644 index 0000000..ae1fc56 --- /dev/null +++ b/app/api/conversations/[id]/participants/route.ts @@ -0,0 +1,171 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getCurrentUser } from '@/lib/auth'; + +// POST - Ajouter des participants à une conversation +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const body = await request.json(); + const { userIds } = body; + + if (!userIds || !Array.isArray(userIds) || userIds.length === 0) { + return NextResponse.json( + { error: 'Au moins un utilisateur est requis' }, + { status: 400 } + ); + } + + const conversation = await prisma.conversation.findUnique({ + where: { id: params.id }, + include: { + participants: true, + }, + }); + + if (!conversation) { + return NextResponse.json({ error: 'Conversation non trouvée' }, { status: 404 }); + } + + // Vérifier que l'utilisateur est participant + const isParticipant = conversation.participants.some((p) => p.userId === user.id); + if (!isParticipant) { + return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 }); + } + + // Seuls les groupes peuvent avoir des participants ajoutés + if (conversation.type !== 'group') { + return NextResponse.json( + { error: 'Seuls les groupes peuvent avoir des participants ajoutés' }, + { status: 400 } + ); + } + + // Vérifier que les utilisateurs existent + const users = await prisma.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + }); + + if (users.length !== userIds.length) { + return NextResponse.json( + { error: 'Un ou plusieurs utilisateurs sont invalides' }, + { status: 400 } + ); + } + + // Filtrer les utilisateurs déjà participants + const existingParticipantIds = conversation.participants.map((p) => p.userId); + const newUserIds = userIds.filter((id: string) => !existingParticipantIds.includes(id)); + + if (newUserIds.length === 0) { + return NextResponse.json( + { error: 'Tous les utilisateurs sont déjà participants' }, + { status: 400 } + ); + } + + // Ajouter les nouveaux participants + await prisma.conversationParticipant.createMany({ + data: newUserIds.map((userId: string) => ({ + conversationId: params.id, + userId, + })), + }); + + const updatedConversation = await prisma.conversation.findUnique({ + where: { id: params.id }, + include: { + participants: { + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }, + }, + }); + + return NextResponse.json(updatedConversation); + } catch (error) { + console.error('Erreur lors de l\'ajout des participants:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} + +// DELETE - Retirer un participant d'une conversation +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const searchParams = request.nextUrl.searchParams; + const userId = searchParams.get('userId'); + + if (!userId) { + return NextResponse.json({ error: 'userId est requis' }, { status: 400 }); + } + + const conversation = await prisma.conversation.findUnique({ + where: { id: params.id }, + include: { + participants: true, + }, + }); + + if (!conversation) { + return NextResponse.json({ error: 'Conversation non trouvée' }, { status: 404 }); + } + + // Vérifier que l'utilisateur est participant + const isParticipant = conversation.participants.some((p) => p.userId === user.id); + if (!isParticipant) { + return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 }); + } + + // Seuls les groupes peuvent avoir des participants retirés + if (conversation.type !== 'group') { + return NextResponse.json( + { error: 'Seuls les groupes peuvent avoir des participants retirés' }, + { status: 400 } + ); + } + + // Vérifier que le participant existe dans la conversation + const participant = conversation.participants.find((p) => p.userId === userId); + if (!participant) { + return NextResponse.json( + { error: 'Participant non trouvé dans la conversation' }, + { status: 404 } + ); + } + + await prisma.conversationParticipant.delete({ + where: { id: participant.id }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Erreur lors du retrait du participant:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} diff --git a/app/api/conversations/[id]/read/route.ts b/app/api/conversations/[id]/read/route.ts new file mode 100644 index 0000000..84a4491 --- /dev/null +++ b/app/api/conversations/[id]/read/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getCurrentUser } from '@/lib/auth'; + +// POST - Marquer les messages comme lus +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const conversation = await prisma.conversation.findUnique({ + where: { id: params.id }, + include: { + participants: true, + }, + }); + + if (!conversation) { + return NextResponse.json({ error: 'Conversation non trouvée' }, { status: 404 }); + } + + // Vérifier que l'utilisateur est participant + const isParticipant = conversation.participants.some((p) => p.userId === user.id); + if (!isParticipant) { + return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 }); + } + + // Mettre à jour lastReadAt pour l'utilisateur + await prisma.conversationParticipant.updateMany({ + where: { + conversationId: params.id, + userId: user.id, + }, + data: { + lastReadAt: new Date(), + }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Erreur lors de la mise à jour de la lecture:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} + +// GET - Récupérer les informations de lecture pour une conversation +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const conversation = await prisma.conversation.findUnique({ + where: { id: params.id }, + include: { + participants: { + select: { + userId: true, + lastReadAt: true, + }, + }, + }, + }); + + if (!conversation) { + return NextResponse.json({ error: 'Conversation non trouvée' }, { status: 404 }); + } + + // Vérifier que l'utilisateur est participant + const isParticipant = conversation.participants.some((p) => p.userId === user.id); + if (!isParticipant) { + return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 }); + } + + return NextResponse.json({ + participants: conversation.participants.map((p) => ({ + userId: p.userId, + lastReadAt: p.lastReadAt, + })), + }); + } catch (error) { + console.error('Erreur lors de la récupération des lectures:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} diff --git a/app/api/conversations/[id]/route.ts b/app/api/conversations/[id]/route.ts new file mode 100644 index 0000000..fd20f64 --- /dev/null +++ b/app/api/conversations/[id]/route.ts @@ -0,0 +1,163 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getCurrentUser } from '@/lib/auth'; + +// GET - Récupérer une conversation spécifique +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const conversation = await prisma.conversation.findUnique({ + where: { id: params.id }, + include: { + participants: { + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }, + }, + }); + + if (!conversation) { + return NextResponse.json({ error: 'Conversation non trouvée' }, { status: 404 }); + } + + // Vérifier que l'utilisateur est participant + const isParticipant = conversation.participants.some((p) => p.userId === user.id); + if (!isParticipant) { + return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 }); + } + + return NextResponse.json(conversation); + } catch (error) { + console.error('Erreur lors de la récupération de la conversation:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} + +// PUT - Mettre à jour une conversation (nom du groupe, etc.) +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const body = await request.json(); + const { name } = body; + + const conversation = await prisma.conversation.findUnique({ + where: { id: params.id }, + include: { + participants: true, + }, + }); + + if (!conversation) { + return NextResponse.json({ error: 'Conversation non trouvée' }, { status: 404 }); + } + + // Vérifier que l'utilisateur est participant + const isParticipant = conversation.participants.some((p) => p.userId === user.id); + if (!isParticipant) { + return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 }); + } + + // Seuls les groupes peuvent être renommés + if (conversation.type !== 'group') { + return NextResponse.json( + { error: 'Seuls les groupes peuvent être renommés' }, + { status: 400 } + ); + } + + const updatedConversation = await prisma.conversation.update({ + where: { id: params.id }, + data: { name }, + include: { + participants: { + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }, + }, + }); + + return NextResponse.json(updatedConversation); + } catch (error) { + console.error('Erreur lors de la mise à jour de la conversation:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} + +// DELETE - Supprimer une conversation (ou quitter un groupe) +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const conversation = await prisma.conversation.findUnique({ + where: { id: params.id }, + include: { + participants: true, + }, + }); + + if (!conversation) { + return NextResponse.json({ error: 'Conversation non trouvée' }, { status: 404 }); + } + + // Vérifier que l'utilisateur est participant + const isParticipant = conversation.participants.some((p) => p.userId === user.id); + if (!isParticipant) { + return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 }); + } + + // Si c'est un groupe, retirer l'utilisateur + // Si c'est une conversation directe, supprimer la conversation + if (conversation.type === 'group') { + await prisma.conversationParticipant.deleteMany({ + where: { + conversationId: params.id, + userId: user.id, + }, + }); + } else { + // Pour les conversations directes, supprimer complètement + await prisma.conversation.delete({ + where: { id: params.id }, + }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Erreur lors de la suppression de la conversation:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} diff --git a/app/api/conversations/[id]/typing/route.ts b/app/api/conversations/[id]/typing/route.ts new file mode 100644 index 0000000..add64fd --- /dev/null +++ b/app/api/conversations/[id]/typing/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getCurrentUser } from '@/lib/auth'; + +// Store temporaire pour les indicateurs de frappe (en production, utiliser Redis ou une DB) +const typingUsers = new Map(); + +// Nettoyer les anciens indicateurs toutes les 5 secondes +setInterval(() => { + const now = Date.now(); + for (const [key, value] of typingUsers.entries()) { + if (now - value.timestamp > 5000) { + typingUsers.delete(key); + } + } +}, 5000); + +// POST - Signaler que l'utilisateur est en train d'écrire +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const conversation = await prisma.conversation.findUnique({ + where: { id: params.id }, + include: { + participants: true, + }, + }); + + if (!conversation) { + return NextResponse.json({ error: 'Conversation non trouvée' }, { status: 404 }); + } + + // Vérifier que l'utilisateur est participant + const isParticipant = conversation.participants.some((p) => p.userId === user.id); + if (!isParticipant) { + return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 }); + } + + // Enregistrer que l'utilisateur est en train d'écrire + const key = `${params.id}:${user.id}`; + typingUsers.set(key, { + userId: user.id, + timestamp: Date.now(), + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Erreur lors de la signalisation du typing:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} + +// GET - Récupérer les utilisateurs en train d'écrire +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const conversation = await prisma.conversation.findUnique({ + where: { id: params.id }, + include: { + participants: true, + }, + }); + + if (!conversation) { + return NextResponse.json({ error: 'Conversation non trouvée' }, { status: 404 }); + } + + // Vérifier que l'utilisateur est participant + const isParticipant = conversation.participants.some((p) => p.userId === user.id); + if (!isParticipant) { + return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 }); + } + + // Récupérer les utilisateurs en train d'écrire (sauf l'utilisateur actuel) + const typing = Array.from(typingUsers.entries()) + .filter(([key]) => key.startsWith(`${params.id}:`)) + .filter(([key]) => !key.endsWith(`:${user.id}`)) + .map(([, value]) => value.userId); + + // Récupérer les informations des utilisateurs + const typingUsersData = await prisma.user.findMany({ + where: { + id: { + in: typing, + }, + }, + select: { + id: true, + email: true, + name: true, + }, + }); + + return NextResponse.json({ typing: typingUsersData }); + } catch (error) { + console.error('Erreur lors de la récupération du typing:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} diff --git a/app/api/conversations/route.ts b/app/api/conversations/route.ts new file mode 100644 index 0000000..8730bd1 --- /dev/null +++ b/app/api/conversations/route.ts @@ -0,0 +1,236 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getCurrentUser } from '@/lib/auth'; + +// GET - Liste toutes les conversations de l'utilisateur +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const conversations = await prisma.conversation.findMany({ + where: { + participants: { + some: { + userId: user.id, + }, + }, + }, + include: { + participants: { + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }, + messages: { + orderBy: { + createdAt: 'desc', + }, + take: 1, + include: { + sender: { + select: { + id: true, + name: true, + email: true, + }, + }, + files: true, + }, + }, + }, + orderBy: { + updatedAt: 'desc', + }, + }); + + // Formater les conversations avec les informations nécessaires + const formattedConversations = await Promise.all( + conversations.map(async (conv) => { + const lastMessage = conv.messages[0] || null; + const otherParticipants = conv.participants + .filter((p) => p.userId !== user.id) + .map((p) => p.user); + + // Pour les conversations directes, utiliser le nom de l'autre participant + const displayName = + conv.type === 'group' + ? conv.name || 'Groupe sans nom' + : otherParticipants[0]?.name || otherParticipants[0]?.email || 'Utilisateur'; + + // Récupérer lastReadAt pour l'utilisateur actuel + const userParticipant = conv.participants.find((p) => p.userId === user.id); + const lastReadAt = userParticipant?.lastReadAt || null; + + // Compter les messages non lus + let unreadCount = 0; + if (lastMessage && lastReadAt) { + const unreadMessages = await prisma.message.count({ + where: { + conversationId: conv.id, + senderId: { not: user.id }, // Exclure les messages de l'utilisateur + createdAt: { gt: lastReadAt }, + }, + }); + unreadCount = unreadMessages; + } else if (lastMessage && !lastReadAt) { + // Si jamais lu, compter tous les messages qui ne sont pas de l'utilisateur + const totalUnread = await prisma.message.count({ + where: { + conversationId: conv.id, + senderId: { not: user.id }, + }, + }); + unreadCount = totalUnread; + } + + return { + id: conv.id, + name: conv.name, + type: conv.type, + displayName, + participants: conv.participants.map((p) => ({ + id: p.user.id, + email: p.user.email, + name: p.user.name, + })), + lastMessage: lastMessage + ? { + id: lastMessage.id, + content: lastMessage.content, + senderId: lastMessage.senderId, + senderName: lastMessage.sender.name || lastMessage.sender.email, + createdAt: lastMessage.createdAt, + hasFiles: lastMessage.files.length > 0, + } + : null, + unreadCount, + updatedAt: conv.updatedAt, + createdAt: conv.createdAt, + }; + }) + ); + + return NextResponse.json(formattedConversations); + } catch (error) { + console.error('Erreur lors de la récupération des conversations:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} + +// POST - Créer une nouvelle conversation +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const body = await request.json(); + const { participantIds, name, type } = body; + + if (!participantIds || !Array.isArray(participantIds) || participantIds.length === 0) { + return NextResponse.json( + { error: 'Au moins un participant est requis' }, + { status: 400 } + ); + } + + // Filtrer l'utilisateur actuel de la liste des participants (on ne peut pas créer une conversation avec soi-même) + const filteredParticipantIds = participantIds.filter((id: string) => id !== user.id); + + if (filteredParticipantIds.length === 0) { + return NextResponse.json( + { error: 'Vous ne pouvez pas créer une conversation avec vous-même' }, + { status: 400 } + ); + } + + // Vérifier que tous les participants existent + const participants = await prisma.user.findMany({ + where: { + id: { + in: filteredParticipantIds, + }, + }, + }); + + if (participants.length !== filteredParticipantIds.length) { + return NextResponse.json( + { error: 'Un ou plusieurs participants sont invalides' }, + { status: 400 } + ); + } + + // Pour les conversations directes, vérifier si une conversation existe déjà + if (type === 'direct' && filteredParticipantIds.length === 1) { + const existingConversation = await prisma.conversation.findFirst({ + where: { + type: 'direct', + participants: { + every: { + userId: { + in: [user.id, filteredParticipantIds[0]], + }, + }, + }, + }, + include: { + participants: true, + }, + }); + + if (existingConversation) { + // Vérifier que les deux participants sont bien dans cette conversation + const participantUserIds = existingConversation.participants.map((p) => p.userId); + if ( + participantUserIds.includes(user.id) && + participantUserIds.includes(filteredParticipantIds[0]) && + participantUserIds.length === 2 + ) { + return NextResponse.json(existingConversation); + } + } + } + + // Créer la conversation avec tous les participants (y compris l'utilisateur actuel) + const conversation = await prisma.conversation.create({ + data: { + name: type === 'group' ? name : null, + type: type || 'direct', + participants: { + create: [ + { userId: user.id }, + ...filteredParticipantIds.map((id: string) => ({ userId: id })), + ], + }, + }, + include: { + participants: { + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }, + }, + }); + + return NextResponse.json(conversation, { status: 201 }); + } catch (error) { + console.error('Erreur lors de la création de la conversation:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} diff --git a/app/api/messages/[id]/route.ts b/app/api/messages/[id]/route.ts new file mode 100644 index 0000000..9613907 --- /dev/null +++ b/app/api/messages/[id]/route.ts @@ -0,0 +1,142 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getCurrentUser } from '@/lib/auth'; + +// GET - Récupérer un message spécifique +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const message = await prisma.message.findUnique({ + where: { id: params.id }, + include: { + sender: { + select: { + id: true, + email: true, + name: true, + }, + }, + conversation: { + include: { + participants: true, + }, + }, + files: true, + }, + }); + + if (!message) { + return NextResponse.json({ error: 'Message non trouvé' }, { status: 404 }); + } + + // Vérifier que l'utilisateur est participant à la conversation + const isParticipant = message.conversation.participants.some( + (p) => p.userId === user.id + ); + if (!isParticipant) { + return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 }); + } + + return NextResponse.json(message); + } catch (error) { + console.error('Erreur lors de la récupération du message:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} + +// PUT - Mettre à jour un message +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const body = await request.json(); + const { content } = body; + + const message = await prisma.message.findUnique({ + where: { id: params.id }, + }); + + if (!message) { + return NextResponse.json({ error: 'Message non trouvé' }, { status: 404 }); + } + + // Vérifier que l'utilisateur est l'auteur du message + if (message.senderId !== user.id) { + return NextResponse.json( + { error: 'Vous ne pouvez modifier que vos propres messages' }, + { status: 403 } + ); + } + + const updatedMessage = await prisma.message.update({ + where: { id: params.id }, + data: { content }, + include: { + sender: { + select: { + id: true, + email: true, + name: true, + }, + }, + files: true, + }, + }); + + return NextResponse.json(updatedMessage); + } catch (error) { + console.error('Erreur lors de la mise à jour du message:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} + +// DELETE - Supprimer un message +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const message = await prisma.message.findUnique({ + where: { id: params.id }, + }); + + if (!message) { + return NextResponse.json({ error: 'Message non trouvé' }, { status: 404 }); + } + + // Vérifier que l'utilisateur est l'auteur du message + if (message.senderId !== user.id) { + return NextResponse.json( + { error: 'Vous ne pouvez supprimer que vos propres messages' }, + { status: 403 } + ); + } + + await prisma.message.delete({ + where: { id: params.id }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Erreur lors de la suppression du message:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} diff --git a/app/api/trajets/[id]/archive/route.ts b/app/api/trajets/[id]/archive/route.ts new file mode 100644 index 0000000..e4abb5e --- /dev/null +++ b/app/api/trajets/[id]/archive/route.ts @@ -0,0 +1,97 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getCurrentUser } from '@/lib/auth'; + +// POST - Archiver un trajet +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const trajet = await prisma.trajet.update({ + where: { id: params.id }, + data: { + archived: true, + }, + include: { + adherent: { + select: { + id: true, + nom: true, + prenom: true, + telephone: true, + email: true, + }, + }, + chauffeur: { + select: { + id: true, + nom: true, + prenom: true, + telephone: true, + }, + }, + }, + }); + + return NextResponse.json(trajet); + } catch (error) { + console.error('Erreur lors de l\'archivage du trajet:', error); + return NextResponse.json( + { error: 'Erreur serveur' }, + { status: 500 } + ); + } +} + +// DELETE - Restaurer un trajet archivé +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const trajet = await prisma.trajet.update({ + where: { id: params.id }, + data: { + archived: false, + }, + include: { + adherent: { + select: { + id: true, + nom: true, + prenom: true, + telephone: true, + email: true, + }, + }, + chauffeur: { + select: { + id: true, + nom: true, + prenom: true, + telephone: true, + }, + }, + }, + }); + + return NextResponse.json(trajet); + } catch (error) { + console.error('Erreur lors de la restauration du trajet:', error); + return NextResponse.json( + { error: 'Erreur serveur' }, + { status: 500 } + ); + } +} diff --git a/app/api/trajets/archives/route.ts b/app/api/trajets/archives/route.ts new file mode 100644 index 0000000..905a2b6 --- /dev/null +++ b/app/api/trajets/archives/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getCurrentUser } from '@/lib/auth'; + +// GET - Liste tous les trajets archivés +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const searchParams = request.nextUrl.searchParams; + const limit = searchParams.get('limit'); + + const trajets = await prisma.trajet.findMany({ + where: { + archived: true, + }, + include: { + adherent: { + select: { + id: true, + nom: true, + prenom: true, + telephone: true, + email: true, + }, + }, + chauffeur: { + select: { + id: true, + nom: true, + prenom: true, + telephone: true, + }, + }, + }, + orderBy: { + updatedAt: 'desc' as const, + }, + take: limit ? parseInt(limit) : undefined, + }); + + return NextResponse.json(trajets); + } catch (error) { + console.error('Erreur lors de la récupération des trajets archivés:', error); + return NextResponse.json( + { error: 'Erreur serveur' }, + { status: 500 } + ); + } +} diff --git a/app/api/trajets/route.ts b/app/api/trajets/route.ts index 05dd18b..43b18b7 100644 --- a/app/api/trajets/route.ts +++ b/app/api/trajets/route.ts @@ -15,7 +15,9 @@ export async function GET(request: NextRequest) { const startDate = searchParams.get('startDate'); const endDate = searchParams.get('endDate'); - const where: any = {}; + const where: any = { + archived: false, // Exclure les trajets archivés par défaut + }; // Filtrer par date si fourni if (startDate || endDate) { diff --git a/app/api/users/route.ts b/app/api/users/route.ts new file mode 100644 index 0000000..a6ec533 --- /dev/null +++ b/app/api/users/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getCurrentUser } from '@/lib/auth'; + +// GET - Liste tous les utilisateurs (pour sélectionner des participants) +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const searchParams = request.nextUrl.searchParams; + const search = searchParams.get('search'); + + const where: any = {}; + if (search) { + where.OR = [ + { name: { contains: search } }, + { email: { contains: search } }, + ]; + } + + const users = await prisma.user.findMany({ + where, + select: { + id: true, + email: true, + name: true, + createdAt: true, + }, + orderBy: { + name: 'asc', + }, + }); + + return NextResponse.json(users); + } catch (error) { + console.error('Erreur lors de la récupération des utilisateurs:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} diff --git a/app/dashboard/archives/page.tsx b/app/dashboard/archives/page.tsx new file mode 100644 index 0000000..337c539 --- /dev/null +++ b/app/dashboard/archives/page.tsx @@ -0,0 +1,21 @@ +import { getCurrentUser } from '@/lib/auth'; +import { redirect } from 'next/navigation'; +import DashboardLayout from '@/components/DashboardLayout'; +import ArchivesTrajets from '@/components/ArchivesTrajets'; + +export default async function ArchivesPage() { + const user = await getCurrentUser(); + if (!user) { + redirect('/login'); + } + + return ( + +
+

Archives

+

Trajets archivés

+ +
+
+ ); +} diff --git a/app/dashboard/messagerie/page.tsx b/app/dashboard/messagerie/page.tsx new file mode 100644 index 0000000..51b9223 --- /dev/null +++ b/app/dashboard/messagerie/page.tsx @@ -0,0 +1,20 @@ +import { redirect } from 'next/navigation'; +import { getCurrentUser } from '@/lib/auth'; +import DashboardLayout from '@/components/DashboardLayout'; +import Messagerie from '@/components/Messagerie'; + +export default async function MessageriePage() { + const user = await getCurrentUser(); + + if (!user) { + redirect('/login'); + } + + return ( + +
+ +
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index 168df16..bbc4cd1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -99,6 +99,17 @@ body { } } +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + .animate-fadeIn { animation: fadeIn 0.2s ease-out; } @@ -106,3 +117,7 @@ body { .animate-slideUp { animation: slideUp 0.3s ease-out; } + +.animate-slideInRight { + animation: slideInRight 0.3s ease-out; +} diff --git a/app/layout.tsx b/app/layout.tsx index b6c6684..9acc474 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { Poppins } from "next/font/google"; import "./globals.css"; +import { NotificationProvider } from "@/components/NotificationProvider"; +import MessageNotifications from "@/components/MessageNotifications"; const poppins = Poppins({ weight: ["300", "400", "500", "600", "700"], @@ -21,7 +23,12 @@ export default function RootLayout({ }>) { return ( - {children} + + + {children} + + + ); } diff --git a/components/ArchivesTrajets.tsx b/components/ArchivesTrajets.tsx new file mode 100644 index 0000000..f58dae2 --- /dev/null +++ b/components/ArchivesTrajets.tsx @@ -0,0 +1,258 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useNotification } from './NotificationProvider'; +import ConfirmModal from './ConfirmModal'; + +interface Trajet { + id: string; + date: string; + adresseDepart: string; + adresseArrivee: string; + commentaire?: string | null; + statut: string; + archived: boolean; + adherent: { + id: string; + nom: string; + prenom: string; + telephone: string; + email: string; + }; + chauffeur?: { + id: string; + nom: string; + prenom: string; + telephone: string; + } | null; +} + +export default function ArchivesTrajets() { + const { showNotification } = useNotification(); + const [trajets, setTrajets] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [showRestoreConfirm, setShowRestoreConfirm] = useState(false); + const [trajetToRestore, setTrajetToRestore] = useState(null); + + useEffect(() => { + fetchTrajets(); + }, []); + + const fetchTrajets = async () => { + setLoading(true); + try { + const response = await fetch('/api/trajets/archives'); + if (response.ok) { + const data = await response.json(); + setTrajets(data); + } + } catch (error) { + console.error('Erreur lors du chargement des archives:', error); + showNotification('error', 'Erreur lors du chargement des archives'); + } finally { + setLoading(false); + } + }; + + const handleRestoreClick = (trajetId: string) => { + setTrajetToRestore(trajetId); + setShowRestoreConfirm(true); + }; + + const handleRestore = async () => { + if (!trajetToRestore) return; + + setShowRestoreConfirm(false); + try { + const response = await fetch(`/api/trajets/${trajetToRestore}/archive`, { + method: 'DELETE', + }); + + if (response.ok) { + showNotification('success', 'Trajet restauré avec succès'); + fetchTrajets(); + } else { + const error = await response.json(); + showNotification('error', error.error || 'Erreur lors de la restauration du trajet'); + } + } catch (error) { + console.error('Erreur lors de la restauration:', error); + showNotification('error', 'Erreur lors de la restauration du trajet'); + } finally { + setTrajetToRestore(null); + } + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('fr-FR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + }; + + const formatTime = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); + }; + + const getStatutColor = (statut: string) => { + switch (statut) { + case 'Validé': + return 'bg-purple-100 text-purple-700 border-purple-200'; + case 'Terminé': + return 'bg-green-100 text-green-700 border-green-200'; + case 'En cours': + return 'bg-blue-100 text-blue-700 border-blue-200'; + case 'Annulé': + return 'bg-red-100 text-red-700 border-red-200'; + default: + return 'bg-gray-100 text-gray-700 border-gray-200'; + } + }; + + const getInitials = (nom: string, prenom: string) => { + return `${prenom.charAt(0)}${nom.charAt(0)}`.toUpperCase(); + }; + + const filteredTrajets = trajets.filter((trajet) => { + const searchLower = searchTerm.toLowerCase(); + return ( + trajet.adherent.nom.toLowerCase().includes(searchLower) || + trajet.adherent.prenom.toLowerCase().includes(searchLower) || + trajet.adresseDepart.toLowerCase().includes(searchLower) || + trajet.adresseArrivee.toLowerCase().includes(searchLower) || + (trajet.chauffeur && `${trajet.chauffeur.prenom} ${trajet.chauffeur.nom}`.toLowerCase().includes(searchLower)) + ); + }); + + if (loading) { + return ( +
+
Chargement des archives...
+
+ ); + } + + return ( +
+ {/* Barre de recherche */} +
+
+
+ setSearchTerm(e.target.value)} + className="w-full px-4 py-2.5 pl-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-lblue focus:border-transparent" + /> + + + +
+
+
+ + {/* Liste des trajets archivés */} +
+ {filteredTrajets.length === 0 ? ( +
+ {searchTerm ? 'Aucun trajet trouvé' : 'Aucun trajet archivé'} +
+ ) : ( +
+ {filteredTrajets.map((trajet) => ( +
+
+
+
+
+ {getInitials(trajet.adherent.nom, trajet.adherent.prenom)} +
+
+
+ {trajet.adherent.prenom} {trajet.adherent.nom} +
+
+ {formatDate(trajet.date)} à {formatTime(trajet.date)} +
+
+ + {trajet.statut} + +
+ +
+
+
+ A +
+
+
Départ
+
{trajet.adresseDepart}
+
+
+
+
+ B +
+
+
Arrivée
+
{trajet.adresseArrivee}
+
+
+
+ + {trajet.chauffeur && ( +
+ + + + Chauffeur: {trajet.chauffeur.prenom} {trajet.chauffeur.nom} +
+ )} +
+ + +
+
+ ))} +
+ )} +
+ + { + setShowRestoreConfirm(false); + setTrajetToRestore(null); + }} + /> +
+ ); +} diff --git a/components/CalendrierTrajets.tsx b/components/CalendrierTrajets.tsx index dc9238d..3147cb6 100644 --- a/components/CalendrierTrajets.tsx +++ b/components/CalendrierTrajets.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, useDraggable, useDroppable } from '@dnd-kit/core'; import { CSS } from '@dnd-kit/utilities'; import TrajetDetailModal from './TrajetDetailModal'; +import { useNotification } from './NotificationProvider'; interface Trajet { id: string; @@ -152,6 +153,7 @@ function DroppableDayCell({ } export default function CalendrierTrajets({ refreshTrigger }: CalendrierTrajetsProps) { + const { showNotification } = useNotification(); const [trajets, setTrajets] = useState([]); const [loading, setLoading] = useState(true); const [currentDate, setCurrentDate] = useState(new Date()); @@ -308,15 +310,21 @@ export default function CalendrierTrajets({ refreshTrigger }: CalendrierTrajetsP }); if (response.ok) { + const targetDateFormatted = new Date(newDate).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + showNotification('success', `Trajet déplacé vers le ${targetDateFormatted}`); // Utiliser fetchTrajets sans loading pour éviter de vider les trajets pendant le chargement await fetchTrajets(false); } else { const error = await response.json(); - alert(error.error || 'Erreur lors du déplacement du trajet'); + showNotification('error', error.error || 'Erreur lors du déplacement du trajet'); } } catch (error) { console.error('Erreur lors du déplacement:', error); - alert('Erreur lors du déplacement du trajet'); + showNotification('error', 'Erreur lors du déplacement du trajet'); } } }; diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx new file mode 100644 index 0000000..c64f55c --- /dev/null +++ b/components/ChatWindow.tsx @@ -0,0 +1,589 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import useSWR from 'swr'; + +interface User { + id: string; + email: string; + name: string | null; +} + +interface Participant { + id: string; + email: string; + name: string | null; +} + +interface MessageFile { + id: string; + filename: string; + filepath: string; + fileType: string; + fileSize: number; +} + +interface Message { + id: string; + content: string | null; + senderId: string; + sender: User; + createdAt: string; + files: MessageFile[]; +} + +interface Conversation { + id: string; + name: string | null; + type: string; + displayName: string; + participants: Participant[]; +} + +interface ChatWindowProps { + conversationId: string; + conversation: Conversation; + onNewMessage: () => void; + onShowGroupSettings: () => void; +} + +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +export default function ChatWindow({ + conversationId, + conversation, + onNewMessage, + onShowGroupSettings, +}: ChatWindowProps) { + const [message, setMessage] = useState(''); + const [files, setFiles] = useState([]); + const [isSending, setIsSending] = useState(false); + const [currentUser, setCurrentUser] = useState(null); + const [typingUsers, setTypingUsers] = useState([]); + const [readReceipts, setReadReceipts] = useState>({}); + const messagesEndRef = useRef(null); + const fileInputRef = useRef(null); + const typingTimeoutRef = useRef(null); + const lastTypingSignalRef = useRef(0); + + // Récupérer l'utilisateur actuel + const { data: userData } = useSWR('/api/auth/me', fetcher); + + useEffect(() => { + if (userData) { + setCurrentUser(userData); + } + }, [userData]); + + const { data: messagesData, error, mutate } = useSWR<{ + messages: Message[]; + hasMore: boolean; + cursor: string | null; + }>(`/api/conversations/${conversationId}/messages?limit=50`, fetcher, { + refreshInterval: 1000, // Rafraîchir toutes les secondes pour le temps réel + }); + + const messages = messagesData?.messages || []; + + // Scroll vers le bas quand de nouveaux messages arrivent + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // Marquer les messages comme lus quand on les voit + useEffect(() => { + if (messages.length > 0) { + fetch(`/api/conversations/${conversationId}/read`, { + method: 'POST', + }).catch(console.error); + } + }, [conversationId, messages.length]); + + // Récupérer les informations de lecture + useEffect(() => { + const fetchReadReceipts = async () => { + try { + const response = await fetch(`/api/conversations/${conversationId}/read`); + if (response.ok) { + const data = await response.json(); + const receipts: Record = {}; + data.participants.forEach((p: { userId: string; lastReadAt: string | null }) => { + receipts[p.userId] = p.lastReadAt ? new Date(p.lastReadAt) : null; + }); + setReadReceipts(receipts); + } + } catch (error) { + console.error('Erreur lors de la récupération des lectures:', error); + } + }; + + fetchReadReceipts(); + const interval = setInterval(fetchReadReceipts, 2000); + return () => clearInterval(interval); + }, [conversationId]); + + // Récupérer les utilisateurs en train d'écrire + useEffect(() => { + const fetchTyping = async () => { + try { + const response = await fetch(`/api/conversations/${conversationId}/typing`); + if (response.ok) { + const data = await response.json(); + setTypingUsers(data.typing || []); + } + } catch (error) { + console.error('Erreur lors de la récupération du typing:', error); + } + }; + + fetchTyping(); + const interval = setInterval(fetchTyping, 1000); + return () => clearInterval(interval); + }, [conversationId]); + + // Écouter les événements SSE pour les nouveaux messages + useEffect(() => { + const eventSource = new EventSource(`/api/conversations/${conversationId}/events`); + + eventSource.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.type === 'new_message') { + mutate(); + onNewMessage(); + // Marquer comme lu immédiatement + fetch(`/api/conversations/${conversationId}/read`, { + method: 'POST', + }).catch(console.error); + } + }; + + eventSource.onerror = () => { + eventSource.close(); + }; + + return () => { + eventSource.close(); + }; + }, [conversationId, mutate, onNewMessage]); + + const handleSendMessage = async () => { + if ((!message.trim() && files.length === 0) || isSending) return; + + setIsSending(true); + const formData = new FormData(); + if (message.trim()) { + formData.append('content', message); + } + files.forEach((file) => { + formData.append('files', file); + }); + + try { + const response = await fetch(`/api/conversations/${conversationId}/messages`, { + method: 'POST', + body: formData, + }); + + if (response.ok) { + setMessage(''); + setFiles([]); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + mutate(); + onNewMessage(); + // Marquer comme lu immédiatement après l'envoi + fetch(`/api/conversations/${conversationId}/read`, { + method: 'POST', + }).catch(console.error); + } else { + const error = await response.json(); + alert(error.error || 'Erreur lors de l\'envoi du message'); + } + } catch (error) { + console.error('Erreur:', error); + alert('Erreur lors de l\'envoi du message'); + } finally { + setIsSending(false); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } else { + // Signaler que l'utilisateur est en train d'écrire + const now = Date.now(); + if (now - lastTypingSignalRef.current > 1000) { + // Envoyer le signal toutes les secondes maximum + lastTypingSignalRef.current = now; + fetch(`/api/conversations/${conversationId}/typing`, { + method: 'POST', + }).catch(console.error); + } + + // Réinitialiser le timeout + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + } + }; + + const handleMessageChange = (e: React.ChangeEvent) => { + setMessage(e.target.value); + + // Signaler que l'utilisateur est en train d'écrire + const now = Date.now(); + if (now - lastTypingSignalRef.current > 1000) { + lastTypingSignalRef.current = now; + fetch(`/api/conversations/${conversationId}/typing`, { + method: 'POST', + }).catch(console.error); + } + + // Réinitialiser le timeout + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + if (e.target.files) { + setFiles(Array.from(e.target.files)); + } + }; + + const removeFile = (index: number) => { + setFiles(files.filter((_, i) => i !== index)); + }; + + const formatFileSize = (bytes: number) => { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + }; + + const isImageFile = (fileType: string) => { + return fileType.startsWith('image/'); + }; + + return ( +
+ {/* En-tête */} +
+
+
+ + {conversation.type === 'group' + ? conversation.displayName.charAt(0).toUpperCase() + : conversation.displayName + .split(' ') + .map((n) => n.charAt(0)) + .join('') + .toUpperCase() + .slice(0, 2)} + +
+
+

{conversation.displayName}

+ {conversation.type === 'group' && ( +

+ {conversation.participants.length} participant{conversation.participants.length > 1 ? 's' : ''} +

+ )} +
+
+ {conversation.type === 'group' && ( + + )} +
+ + {/* Messages */} +
+ {error && ( +
+ Erreur lors du chargement des messages +
+ )} + {!messagesData && !error && ( +
Chargement des messages...
+ )} + {messages.length === 0 && messagesData && ( +
+ Aucun message. Commencez la conversation ! +
+ )} +
+ {messages.map((msg) => { + const isOwnMessage = currentUser && msg.senderId === currentUser.id; + return ( +
+
+ {!isOwnMessage && conversation.type === 'group' && ( +
+ {msg.sender.name || msg.sender.email} +
+ )} + {msg.content && ( +

+ {msg.content} +

+ )} + {msg.files && msg.files.length > 0 && ( +
+ {msg.files.map((file) => ( +
+ {isImageFile(file.fileType) ? ( + + {file.filename} + + ) : ( + + + + +
+
{file.filename}
+
{formatFileSize(file.fileSize)}
+
+
+ )} +
+ ))} +
+ )} +
+ + {new Date(msg.createdAt).toLocaleTimeString('fr-FR', { + hour: '2-digit', + minute: '2-digit', + })} + + {isOwnMessage && ( +
+ {/* Checkmarks pour les messages envoyés/lus */} + {(() => { + // Filtrer les autres participants (pas l'utilisateur actuel) + const otherParticipants = conversation.participants.filter( + (p) => p.id !== currentUser?.id + ); + // Vérifier si tous les autres participants ont lu le message + const allRead = otherParticipants.length > 0 && otherParticipants.every((p) => { + const lastRead = readReceipts[p.id]; + if (!lastRead) return false; + return new Date(lastRead) >= new Date(msg.createdAt); + }); + + // Premier checkmark (toujours visible) + const firstCheck = ( + + + + ); + + // Deuxième checkmark (seulement si lu) + const secondCheck = allRead ? ( + + + + ) : null; + + return ( + <> + {firstCheck} + {secondCheck} + + ); + })()} +
+ )} +
+
+
+ ); + })} +
+ + {/* Indicateur de frappe */} + {typingUsers.length > 0 && ( +
+
+
+
+
+
+
+
+ + {typingUsers.length === 1 + ? `${typingUsers[0].name || typingUsers[0].email} est en train d'écrire...` + : `${typingUsers.length} personnes sont en train d'écrire...`} + +
+
+
+ )} + +
+
+ + {/* Zone de saisie */} +
+ {/* Fichiers sélectionnés */} + {files.length > 0 && ( +
+ {files.map((file, index) => ( +
+ {file.name} + +
+ ))} +
+ )} + +
+ + +