Added Chat Page
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -37,3 +37,6 @@ next-env.d.ts
|
|||||||
|
|
||||||
# prisma
|
# prisma
|
||||||
/prisma/migrations
|
/prisma/migrations
|
||||||
|
|
||||||
|
# uploads
|
||||||
|
/public/uploads
|
||||||
|
|||||||
16
app/api/auth/me/route.ts
Normal file
16
app/api/auth/me/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
149
app/api/conversations/[id]/events/route.ts
Normal file
149
app/api/conversations/[id]/events/route.ts
Normal file
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
220
app/api/conversations/[id]/messages/route.ts
Normal file
220
app/api/conversations/[id]/messages/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
171
app/api/conversations/[id]/participants/route.ts
Normal file
171
app/api/conversations/[id]/participants/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/api/conversations/[id]/read/route.ts
Normal file
94
app/api/conversations/[id]/read/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
163
app/api/conversations/[id]/route.ts
Normal file
163
app/api/conversations/[id]/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
113
app/api/conversations/[id]/typing/route.ts
Normal file
113
app/api/conversations/[id]/typing/route.ts
Normal file
@@ -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<string, { userId: string; timestamp: number }>();
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
236
app/api/conversations/route.ts
Normal file
236
app/api/conversations/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/api/messages/[id]/route.ts
Normal file
142
app/api/messages/[id]/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/api/trajets/[id]/archive/route.ts
Normal file
97
app/api/trajets/[id]/archive/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/api/trajets/archives/route.ts
Normal file
53
app/api/trajets/archives/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,9 @@ export async function GET(request: NextRequest) {
|
|||||||
const startDate = searchParams.get('startDate');
|
const startDate = searchParams.get('startDate');
|
||||||
const endDate = searchParams.get('endDate');
|
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
|
// Filtrer par date si fourni
|
||||||
if (startDate || endDate) {
|
if (startDate || endDate) {
|
||||||
|
|||||||
42
app/api/users/route.ts
Normal file
42
app/api/users/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/dashboard/archives/page.tsx
Normal file
21
app/dashboard/archives/page.tsx
Normal file
@@ -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 (
|
||||||
|
<DashboardLayout user={user}>
|
||||||
|
<div className="p-8">
|
||||||
|
<h1 className="text-3xl font-semibold text-cblack mb-2">Archives</h1>
|
||||||
|
<p className="text-sm text-cgray mb-8">Trajets archivés</p>
|
||||||
|
<ArchivesTrajets />
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/dashboard/messagerie/page.tsx
Normal file
20
app/dashboard/messagerie/page.tsx
Normal file
@@ -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 (
|
||||||
|
<DashboardLayout user={user}>
|
||||||
|
<div className="h-full">
|
||||||
|
<Messagerie />
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -99,6 +99,17 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.animate-fadeIn {
|
.animate-fadeIn {
|
||||||
animation: fadeIn 0.2s ease-out;
|
animation: fadeIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
@@ -106,3 +117,7 @@ body {
|
|||||||
.animate-slideUp {
|
.animate-slideUp {
|
||||||
animation: slideUp 0.3s ease-out;
|
animation: slideUp 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.animate-slideInRight {
|
||||||
|
animation: slideInRight 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Poppins } from "next/font/google";
|
import { Poppins } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { NotificationProvider } from "@/components/NotificationProvider";
|
||||||
|
import MessageNotifications from "@/components/MessageNotifications";
|
||||||
|
|
||||||
const poppins = Poppins({
|
const poppins = Poppins({
|
||||||
weight: ["300", "400", "500", "600", "700"],
|
weight: ["300", "400", "500", "600", "700"],
|
||||||
@@ -21,7 +23,12 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<body className={poppins.variable}>{children}</body>
|
<body className={poppins.variable}>
|
||||||
|
<NotificationProvider>
|
||||||
|
{children}
|
||||||
|
<MessageNotifications />
|
||||||
|
</NotificationProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
258
components/ArchivesTrajets.tsx
Normal file
258
components/ArchivesTrajets.tsx
Normal file
@@ -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<Trajet[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
|
||||||
|
const [trajetToRestore, setTrajetToRestore] = useState<string | null>(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 (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm p-8">
|
||||||
|
<div className="text-center text-gray-500">Chargement des archives...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm">
|
||||||
|
{/* Barre de recherche */}
|
||||||
|
<div className="p-6 border-b border-gray-200">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Rechercher dans les archives..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des trajets archivés */}
|
||||||
|
<div className="p-6">
|
||||||
|
{filteredTrajets.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
{searchTerm ? 'Aucun trajet trouvé' : 'Aucun trajet archivé'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredTrajets.map((trajet) => (
|
||||||
|
<div
|
||||||
|
key={trajet.id}
|
||||||
|
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-lgreen flex items-center justify-center text-white font-semibold text-sm">
|
||||||
|
{getInitials(trajet.adherent.nom, trajet.adherent.prenom)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-gray-900">
|
||||||
|
{trajet.adherent.prenom} {trajet.adherent.nom}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{formatDate(trajet.date)} à {formatTime(trajet.date)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded border ${getStatutColor(trajet.statut)}`}>
|
||||||
|
{trajet.statut}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-lgreen flex items-center justify-center text-white text-xs font-bold mt-0.5 flex-shrink-0">
|
||||||
|
A
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500">Départ</div>
|
||||||
|
<div className="text-sm text-gray-900">{trajet.adresseDepart}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-lblue flex items-center justify-center text-white text-xs font-bold mt-0.5 flex-shrink-0">
|
||||||
|
B
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500">Arrivée</div>
|
||||||
|
<div className="text-sm text-gray-900">{trajet.adresseArrivee}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{trajet.chauffeur && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>Chauffeur: {trajet.chauffeur.prenom} {trajet.chauffeur.nom}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleRestoreClick(trajet.id)}
|
||||||
|
className="ml-4 px-4 py-2 text-sm font-medium text-lgreen hover:text-dgreen transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
Restaurer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={showRestoreConfirm}
|
||||||
|
title="Restaurer le trajet"
|
||||||
|
message="Êtes-vous sûr de vouloir restaurer ce trajet ? Il sera à nouveau visible dans le calendrier."
|
||||||
|
confirmText="Restaurer"
|
||||||
|
cancelText="Annuler"
|
||||||
|
confirmColor="primary"
|
||||||
|
onConfirm={handleRestore}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowRestoreConfirm(false);
|
||||||
|
setTrajetToRestore(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, useDraggable, useDroppable } from '@dnd-kit/core';
|
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import TrajetDetailModal from './TrajetDetailModal';
|
import TrajetDetailModal from './TrajetDetailModal';
|
||||||
|
import { useNotification } from './NotificationProvider';
|
||||||
|
|
||||||
interface Trajet {
|
interface Trajet {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -152,6 +153,7 @@ function DroppableDayCell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CalendrierTrajets({ refreshTrigger }: CalendrierTrajetsProps) {
|
export default function CalendrierTrajets({ refreshTrigger }: CalendrierTrajetsProps) {
|
||||||
|
const { showNotification } = useNotification();
|
||||||
const [trajets, setTrajets] = useState<Trajet[]>([]);
|
const [trajets, setTrajets] = useState<Trajet[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentDate, setCurrentDate] = useState(new Date());
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
@@ -308,15 +310,21 @@ export default function CalendrierTrajets({ refreshTrigger }: CalendrierTrajetsP
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
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
|
// Utiliser fetchTrajets sans loading pour éviter de vider les trajets pendant le chargement
|
||||||
await fetchTrajets(false);
|
await fetchTrajets(false);
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
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) {
|
} catch (error) {
|
||||||
console.error('Erreur lors du déplacement:', 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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
589
components/ChatWindow.tsx
Normal file
589
components/ChatWindow.tsx
Normal file
@@ -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<File[]>([]);
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
|
const [typingUsers, setTypingUsers] = useState<User[]>([]);
|
||||||
|
const [readReceipts, setReadReceipts] = useState<Record<string, Date | null>>({});
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const lastTypingSignalRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// Récupérer l'utilisateur actuel
|
||||||
|
const { data: userData } = useSWR<User>('/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<string, Date | null> = {};
|
||||||
|
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<HTMLTextAreaElement>) => {
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* En-tête */}
|
||||||
|
<div className="p-4 border-b border-gray-200 flex items-center justify-between bg-white">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-lblue to-dblue flex items-center justify-center">
|
||||||
|
<span className="text-white font-semibold text-sm">
|
||||||
|
{conversation.type === 'group'
|
||||||
|
? conversation.displayName.charAt(0).toUpperCase()
|
||||||
|
: conversation.displayName
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n.charAt(0))
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">{conversation.displayName}</h3>
|
||||||
|
{conversation.type === 'group' && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{conversation.participants.length} participant{conversation.participants.length > 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{conversation.type === 'group' && (
|
||||||
|
<button
|
||||||
|
onClick={onShowGroupSettings}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
title="Paramètres du groupe"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 bg-gray-50">
|
||||||
|
{error && (
|
||||||
|
<div className="text-center text-red-600 py-4">
|
||||||
|
Erreur lors du chargement des messages
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!messagesData && !error && (
|
||||||
|
<div className="text-center text-gray-500 py-4">Chargement des messages...</div>
|
||||||
|
)}
|
||||||
|
{messages.length === 0 && messagesData && (
|
||||||
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
Aucun message. Commencez la conversation !
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{messages.map((msg) => {
|
||||||
|
const isOwnMessage = currentUser && msg.senderId === currentUser.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[70%] rounded-lg p-3 ${
|
||||||
|
isOwnMessage
|
||||||
|
? 'bg-lblue text-white'
|
||||||
|
: 'bg-white text-gray-900 border border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!isOwnMessage && conversation.type === 'group' && (
|
||||||
|
<div className="text-xs font-semibold mb-1 opacity-75">
|
||||||
|
{msg.sender.name || msg.sender.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg.content && (
|
||||||
|
<p className={`text-sm whitespace-pre-wrap ${isOwnMessage ? 'text-white' : ''}`}>
|
||||||
|
{msg.content}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{msg.files && msg.files.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{msg.files.map((file) => (
|
||||||
|
<div key={file.id} className="rounded overflow-hidden">
|
||||||
|
{isImageFile(file.fileType) ? (
|
||||||
|
<a
|
||||||
|
href={file.filepath}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={file.filepath}
|
||||||
|
alt={file.filename}
|
||||||
|
className="rounded max-w-full h-auto max-h-64"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={file.filepath}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={`flex items-center gap-2 p-2 rounded ${
|
||||||
|
isOwnMessage
|
||||||
|
? 'bg-white/20 hover:bg-white/30'
|
||||||
|
: 'bg-gray-100 hover:bg-gray-200'
|
||||||
|
} transition-colors`}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">{file.filename}</div>
|
||||||
|
<div className="text-xs opacity-75">{formatFileSize(file.fileSize)}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`flex items-center gap-2 mt-1 ${isOwnMessage ? 'text-white/70' : 'text-gray-500'}`}>
|
||||||
|
<span className="text-xs">
|
||||||
|
{new Date(msg.createdAt).toLocaleTimeString('fr-FR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{isOwnMessage && (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{/* 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 = (
|
||||||
|
<svg
|
||||||
|
className="w-3.5 h-3.5 text-white/70"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deuxième checkmark (seulement si lu)
|
||||||
|
const secondCheck = allRead ? (
|
||||||
|
<svg
|
||||||
|
className="w-3.5 h-3.5 text-blue-300"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ marginLeft: '-2px' }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{firstCheck}
|
||||||
|
{secondCheck}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Indicateur de frappe */}
|
||||||
|
{typingUsers.length > 0 && (
|
||||||
|
<div className="flex justify-start mb-2">
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg px-4 py-2 max-w-[70%]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
|
||||||
|
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
|
||||||
|
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600">
|
||||||
|
{typingUsers.length === 1
|
||||||
|
? `${typingUsers[0].name || typingUsers[0].email} est en train d'écrire...`
|
||||||
|
: `${typingUsers.length} personnes sont en train d'écrire...`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone de saisie */}
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-white">
|
||||||
|
{/* Fichiers sélectionnés */}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="mb-2 flex flex-wrap gap-2">
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-2 bg-gray-100 rounded-lg px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-gray-700">{file.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeFile(index)}
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
id="file-input"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="file-input"
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||||
|
title="Joindre un fichier"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={message}
|
||||||
|
onChange={handleMessageChange}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
placeholder="Tapez votre message..."
|
||||||
|
className="flex-1 min-h-[44px] max-h-32 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-lblue focus:border-transparent resize-none text-gray-900 placeholder-gray-400"
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={(!message.trim() && files.length === 0) || isSending}
|
||||||
|
className="p-2 rounded-lg bg-lblue text-white hover:bg-dblue transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Envoyer"
|
||||||
|
>
|
||||||
|
{isSending ? (
|
||||||
|
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
components/ConfirmModal.tsx
Normal file
70
components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
confirmColor?: 'primary' | 'danger' | 'warning';
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmModal({
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = 'Confirmer',
|
||||||
|
cancelText = 'Annuler',
|
||||||
|
confirmColor = 'primary',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: ConfirmModalProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const getConfirmButtonStyle = () => {
|
||||||
|
switch (confirmColor) {
|
||||||
|
case 'danger':
|
||||||
|
return 'bg-red-600 hover:bg-red-700 text-white';
|
||||||
|
case 'warning':
|
||||||
|
return 'bg-orange-600 hover:bg-orange-700 text-white';
|
||||||
|
default:
|
||||||
|
return 'bg-lblue hover:bg-dblue text-white';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fadeIn">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-md w-full animate-slideUp border border-gray-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-gray-200 px-6 py-5 bg-white">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">{title}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
<p className="text-sm text-gray-600">{message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-gray-200 px-6 py-4 bg-gray-50/50 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={`px-6 py-2 text-sm font-medium rounded-lg transition-colors ${getConfirmButtonStyle()}`}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useRouter, usePathname } from 'next/navigation';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,10 +23,37 @@ interface NavItem {
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||||
|
|
||||||
export default function DashboardLayout({ user, children }: DashboardLayoutProps) {
|
export default function DashboardLayout({ user, children }: DashboardLayoutProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
|
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||||
|
|
||||||
|
// Récupérer les conversations pour compter les messages non lus
|
||||||
|
const { data: conversations } = useSWR<Array<{ unreadCount: number }>>(
|
||||||
|
'/api/conversations',
|
||||||
|
fetcher,
|
||||||
|
{
|
||||||
|
refreshInterval: 3000, // Rafraîchir toutes les 3 secondes
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculer le nombre total de messages non lus
|
||||||
|
const totalUnreadCount = conversations?.reduce((sum, conv) => sum + (conv.unreadCount || 0), 0) || 0;
|
||||||
|
|
||||||
|
const getUserInitials = () => {
|
||||||
|
if (user.name) {
|
||||||
|
const names = user.name.split(' ');
|
||||||
|
if (names.length >= 2) {
|
||||||
|
return `${names[0].charAt(0)}${names[1].charAt(0)}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return user.name.charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
return user.email.charAt(0).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
@@ -78,9 +106,11 @@ export default function DashboardLayout({ user, children }: DashboardLayoutProps
|
|||||||
label: 'Messagerie',
|
label: 'Messagerie',
|
||||||
href: '/dashboard/messagerie',
|
href: '/dashboard/messagerie',
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="relative">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -92,6 +122,15 @@ export default function DashboardLayout({ user, children }: DashboardLayoutProps
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Archives',
|
||||||
|
href: '/dashboard/archives',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
@@ -131,22 +170,28 @@ export default function DashboardLayout({ user, children }: DashboardLayoutProps
|
|||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const isActive = pathname === item.href;
|
const isActive = pathname === item.href;
|
||||||
|
const isMessagerie = item.href === '/dashboard/messagerie';
|
||||||
|
const showBadge = isMessagerie && totalUnreadCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={item.href}>
|
<li key={item.href}>
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors relative ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-lblue text-white'
|
? 'bg-lblue text-white'
|
||||||
: 'text-gray-700 hover:bg-lblue/10'
|
: 'text-gray-700 hover:bg-lblue/10'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={isActive ? 'text-white' : 'text-lblue'}>
|
<span className={isActive ? 'text-white' : 'text-lblue'}>{item.icon}</span>
|
||||||
{item.icon}
|
<span className={`text-sm font-medium flex-1 ${isActive ? '' : ''}`}>
|
||||||
</span>
|
|
||||||
<span className={`text-sm font-medium ${isActive ? '' : ''}`}>
|
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
|
{showBadge && (
|
||||||
|
<span className="absolute top-2 right-2 w-5 h-5 bg-red-500 text-white text-xs font-semibold rounded-full flex items-center justify-center">
|
||||||
|
{totalUnreadCount > 99 ? '99+' : totalUnreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -168,21 +213,101 @@ export default function DashboardLayout({ user, children }: DashboardLayoutProps
|
|||||||
<header className="bg-white border-b border-gray-200 px-6 py-4">
|
<header className="bg-white border-b border-gray-200 px-6 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-semibold text-gray-900"></h1>
|
<h1 className="text-xl font-semibold text-gray-900"></h1>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
{/* Notification Icon */}
|
{/* Notification Button */}
|
||||||
<button className="relative w-10 h-10 rounded-full bg-[#6B46C1] flex items-center justify-center hover:bg-[#5B21B6] transition-colors">
|
<div className="relative">
|
||||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<button
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
onClick={() => setShowNotifications(!showNotifications)}
|
||||||
</svg>
|
className="relative w-10 h-10 rounded-lg bg-gray-50 hover:bg-gray-100 border border-gray-200 flex items-center justify-center transition-all duration-200 hover:shadow-sm group"
|
||||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 bg-red-500 rounded-full border-2 border-white"></span>
|
>
|
||||||
</button>
|
<svg className="w-5 h-5 text-gray-600 group-hover:text-gray-900 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||||
|
</svg>
|
||||||
|
{/* Badge de notification */}
|
||||||
|
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full border-2 border-white"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Notifications */}
|
||||||
|
{showNotifications && (
|
||||||
|
<div className="absolute right-0 top-12 w-80 bg-white rounded-lg shadow-xl border border-gray-200 z-50 animate-slideUp">
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">Notifications</h3>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
<div className="p-4 text-center text-sm text-gray-500">
|
||||||
|
Aucune notification
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Profile Avatar */}
|
{/* Profile Avatar */}
|
||||||
<button className="w-10 h-10 rounded-full bg-[#6B46C1] flex items-center justify-center hover:bg-[#5B21B6] transition-colors">
|
<div className="relative">
|
||||||
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
|
<button
|
||||||
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
|
onClick={() => setShowProfileMenu(!showProfileMenu)}
|
||||||
</svg>
|
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 border border-gray-200 transition-all duration-200 hover:shadow-sm group"
|
||||||
</button>
|
>
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-lblue to-dblue flex items-center justify-center shadow-sm">
|
||||||
|
<span className="text-white text-sm font-semibold">{getUserInitials()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block text-left">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{user.name || 'Utilisateur'}</div>
|
||||||
|
<div className="text-xs text-gray-500">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
<svg className="w-4 h-4 text-gray-400 group-hover:text-gray-600 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Profile Menu */}
|
||||||
|
{showProfileMenu && (
|
||||||
|
<div className="absolute right-0 top-14 w-56 bg-white rounded-lg shadow-xl border border-gray-200 z-50 animate-slideUp">
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{user.name || 'Utilisateur'}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowProfileMenu(false);
|
||||||
|
// TODO: Navigate to profile page
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
Mon profil
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowProfileMenu(false);
|
||||||
|
// TODO: Navigate to settings
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
Paramètres
|
||||||
|
</button>
|
||||||
|
<div className="border-t border-gray-200 my-1"></div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
|
</svg>
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -192,6 +317,17 @@ export default function DashboardLayout({ user, children }: DashboardLayoutProps
|
|||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Overlay pour fermer les menus */}
|
||||||
|
{(showNotifications || showProfileMenu) && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={() => {
|
||||||
|
setShowNotifications(false);
|
||||||
|
setShowProfileMenu(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
300
components/GroupSettingsModal.tsx
Normal file
300
components/GroupSettingsModal.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } 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 Conversation {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
type: string;
|
||||||
|
displayName: string;
|
||||||
|
participants: Participant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupSettingsModalProps {
|
||||||
|
conversation: Conversation;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||||
|
|
||||||
|
export default function GroupSettingsModal({
|
||||||
|
conversation,
|
||||||
|
onClose,
|
||||||
|
onUpdate,
|
||||||
|
}: GroupSettingsModalProps) {
|
||||||
|
const [groupName, setGroupName] = useState(conversation.name || '');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
|
const { data: users, error } = useSWR<User[]>(
|
||||||
|
search ? `/api/users?search=${encodeURIComponent(search)}` : null,
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingParticipantIds = conversation.participants.map((p) => p.id);
|
||||||
|
|
||||||
|
const handleUpdateName = async () => {
|
||||||
|
if (!groupName.trim()) {
|
||||||
|
alert('Le nom du groupe ne peut pas être vide');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/conversations/${conversation.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: groupName }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
onUpdate();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Erreur lors de la mise à jour');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur:', error);
|
||||||
|
alert('Erreur lors de la mise à jour');
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddParticipants = async () => {
|
||||||
|
if (selectedUsers.length === 0) {
|
||||||
|
alert('Veuillez sélectionner au moins un utilisateur');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAdding(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/conversations/${conversation.id}/participants`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userIds: selectedUsers }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSelectedUsers([]);
|
||||||
|
setSearch('');
|
||||||
|
onUpdate();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Erreur lors de l\'ajout des participants');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur:', error);
|
||||||
|
alert('Erreur lors de l\'ajout des participants');
|
||||||
|
} finally {
|
||||||
|
setIsAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveParticipant = async (userId: string) => {
|
||||||
|
if (!confirm('Êtes-vous sûr de vouloir retirer cet utilisateur du groupe ?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/conversations/${conversation.id}/participants?userId=${userId}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
onUpdate();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Erreur lors du retrait du participant');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur:', error);
|
||||||
|
alert('Erreur lors du retrait du participant');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserToggle = (userId: string) => {
|
||||||
|
if (selectedUsers.includes(userId)) {
|
||||||
|
setSelectedUsers(selectedUsers.filter((id) => id !== userId));
|
||||||
|
} else {
|
||||||
|
setSelectedUsers([...selectedUsers, userId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableUsers = users?.filter((u) => !existingParticipantIds.includes(u.id)) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col">
|
||||||
|
<div className="p-6 border-b border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">Paramètres du groupe</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
|
{/* Nom du groupe */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Nom du groupe
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={groupName}
|
||||||
|
onChange={(e) => setGroupName(e.target.value)}
|
||||||
|
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-lblue focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleUpdateName}
|
||||||
|
disabled={isUpdating || groupName.trim() === conversation.name}
|
||||||
|
className="px-4 py-2 bg-lblue text-white rounded-lg hover:bg-dblue transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isUpdating ? 'Mise à jour...' : 'Mettre à jour'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Participants existants */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Participants ({conversation.participants.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{conversation.participants.map((participant) => (
|
||||||
|
<div
|
||||||
|
key={participant.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{participant.name || 'Utilisateur'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">{participant.email}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveParticipant(participant.id)}
|
||||||
|
className="text-red-600 hover:text-red-700 text-sm"
|
||||||
|
>
|
||||||
|
Retirer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ajouter des participants */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">Ajouter des participants</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Rechercher des utilisateurs..."
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-lblue focus:border-transparent"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<>
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-red-600">Erreur lors de la recherche</div>
|
||||||
|
)}
|
||||||
|
{!users && !error && (
|
||||||
|
<div className="text-sm text-gray-500">Recherche...</div>
|
||||||
|
)}
|
||||||
|
{availableUsers.length === 0 && users && (
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Aucun utilisateur disponible ou déjà dans le groupe
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{availableUsers.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="max-h-48 overflow-y-auto border border-gray-200 rounded-lg divide-y divide-gray-100">
|
||||||
|
{availableUsers.map((user) => (
|
||||||
|
<label
|
||||||
|
key={user.id}
|
||||||
|
className="flex items-center gap-3 p-3 hover:bg-gray-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedUsers.includes(user.id)}
|
||||||
|
onChange={() => handleUserToggle(user.id)}
|
||||||
|
className="rounded border-gray-300 text-lblue focus:ring-lblue"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{user.name || 'Utilisateur'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selectedUsers.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleAddParticipants}
|
||||||
|
disabled={isAdding}
|
||||||
|
className="w-full px-4 py-2 bg-lblue text-white rounded-lg hover:bg-dblue transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isAdding
|
||||||
|
? 'Ajout...'
|
||||||
|
: `Ajouter ${selectedUsers.length} participant${selectedUsers.length > 1 ? 's' : ''}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
components/MessageNotifications.tsx
Normal file
169
components/MessageNotifications.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LastMessage {
|
||||||
|
id: string;
|
||||||
|
content: string | null;
|
||||||
|
senderId: string;
|
||||||
|
senderName: string;
|
||||||
|
createdAt: string;
|
||||||
|
hasFiles: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Conversation {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
type: string;
|
||||||
|
displayName: string;
|
||||||
|
participants: Array<{ id: string; email: string; name: string | null }>;
|
||||||
|
lastMessage: LastMessage | null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||||
|
|
||||||
|
export default function MessageNotifications() {
|
||||||
|
const { data: currentUser } = useSWR<User>('/api/auth/me', fetcher);
|
||||||
|
const { data: conversations } = useSWR<Conversation[]>(
|
||||||
|
currentUser ? '/api/conversations' : null,
|
||||||
|
fetcher,
|
||||||
|
{
|
||||||
|
refreshInterval: 3000, // Vérifier toutes les 3 secondes
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastMessageIdsRef = useRef<Map<string, string>>(new Map());
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
|
// Fonction pour jouer le son de notification
|
||||||
|
const playNotificationSound = () => {
|
||||||
|
try {
|
||||||
|
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
const oscillator = audioContext.createOscillator();
|
||||||
|
const gainNode = audioContext.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(audioContext.destination);
|
||||||
|
|
||||||
|
// Son de notification agréable (deux tons)
|
||||||
|
oscillator.frequency.value = 800;
|
||||||
|
oscillator.type = 'sine';
|
||||||
|
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
|
||||||
|
|
||||||
|
oscillator.start(audioContext.currentTime);
|
||||||
|
oscillator.stop(audioContext.currentTime + 0.2);
|
||||||
|
|
||||||
|
// Deuxième ton après une courte pause
|
||||||
|
setTimeout(() => {
|
||||||
|
const oscillator2 = audioContext.createOscillator();
|
||||||
|
const gainNode2 = audioContext.createGain();
|
||||||
|
|
||||||
|
oscillator2.connect(gainNode2);
|
||||||
|
gainNode2.connect(audioContext.destination);
|
||||||
|
|
||||||
|
oscillator2.frequency.value = 1000;
|
||||||
|
oscillator2.type = 'sine';
|
||||||
|
gainNode2.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||||
|
gainNode2.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
|
||||||
|
|
||||||
|
oscillator2.start(audioContext.currentTime);
|
||||||
|
oscillator2.stop(audioContext.currentTime + 0.2);
|
||||||
|
}, 150);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la lecture du son:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Demander la permission pour les notifications
|
||||||
|
useEffect(() => {
|
||||||
|
if ('Notification' in window && Notification.permission === 'default') {
|
||||||
|
Notification.requestPermission().catch(console.error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialiser les IDs des messages au premier chargement
|
||||||
|
useEffect(() => {
|
||||||
|
if (conversations && !initializedRef.current) {
|
||||||
|
conversations.forEach((conversation) => {
|
||||||
|
if (conversation.lastMessage) {
|
||||||
|
lastMessageIdsRef.current.set(conversation.id, conversation.lastMessage.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
initializedRef.current = true;
|
||||||
|
}
|
||||||
|
}, [conversations]);
|
||||||
|
|
||||||
|
// Détecter les nouveaux messages
|
||||||
|
useEffect(() => {
|
||||||
|
if (!conversations || !currentUser || !initializedRef.current) return;
|
||||||
|
|
||||||
|
conversations.forEach((conversation) => {
|
||||||
|
if (!conversation.lastMessage) return;
|
||||||
|
|
||||||
|
const lastMessageId = lastMessageIdsRef.current.get(conversation.id);
|
||||||
|
const currentMessageId = conversation.lastMessage.id;
|
||||||
|
|
||||||
|
// Nouveau message détecté
|
||||||
|
if (lastMessageId && lastMessageId !== currentMessageId) {
|
||||||
|
// Vérifier si le message n'est pas de l'utilisateur actuel
|
||||||
|
if (conversation.lastMessage.senderId !== currentUser.id) {
|
||||||
|
// Vérifier si on est sur la page de messagerie
|
||||||
|
const isOnMessagingPage = window.location.pathname === '/dashboard/messagerie';
|
||||||
|
|
||||||
|
// Jouer le son seulement si on n'est pas sur la page de messagerie
|
||||||
|
// (sur la page de messagerie, on voit déjà les messages en temps réel)
|
||||||
|
if (!isOnMessagingPage) {
|
||||||
|
playNotificationSound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Afficher la notification si on n'est pas sur la page de messagerie
|
||||||
|
if (!isOnMessagingPage && 'Notification' in window && Notification.permission === 'granted') {
|
||||||
|
const messageContent = conversation.lastMessage.hasFiles && !conversation.lastMessage.content
|
||||||
|
? '📎 Fichier'
|
||||||
|
: conversation.lastMessage.content || '📎 Fichier';
|
||||||
|
|
||||||
|
const notification = new Notification(
|
||||||
|
conversation.type === 'group' ? conversation.displayName : conversation.lastMessage.senderName,
|
||||||
|
{
|
||||||
|
body: messageContent.length > 100
|
||||||
|
? messageContent.substring(0, 100) + '...'
|
||||||
|
: messageContent,
|
||||||
|
icon: '/logo.svg',
|
||||||
|
badge: '/logo.svg',
|
||||||
|
tag: conversation.id, // Permet de remplacer les notifications de la même conversation
|
||||||
|
requireInteraction: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
notification.onclick = () => {
|
||||||
|
window.focus();
|
||||||
|
window.location.href = '/dashboard/messagerie';
|
||||||
|
notification.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fermer automatiquement après 5 secondes
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.close();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour l'ID du dernier message
|
||||||
|
lastMessageIdsRef.current.set(conversation.id, currentMessageId);
|
||||||
|
});
|
||||||
|
}, [conversations, currentUser]);
|
||||||
|
|
||||||
|
return null; // Ce composant ne rend rien visuellement
|
||||||
|
}
|
||||||
224
components/Messagerie.tsx
Normal file
224
components/Messagerie.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import ChatWindow from './ChatWindow';
|
||||||
|
import NewConversationModal from './NewConversationModal';
|
||||||
|
import GroupSettingsModal from './GroupSettingsModal';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Participant {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LastMessage {
|
||||||
|
id: string;
|
||||||
|
content: string | null;
|
||||||
|
senderId: string;
|
||||||
|
senderName: string;
|
||||||
|
createdAt: string;
|
||||||
|
hasFiles: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Conversation {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
type: string;
|
||||||
|
displayName: string;
|
||||||
|
participants: Participant[];
|
||||||
|
lastMessage: LastMessage | null;
|
||||||
|
unreadCount: number;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||||
|
|
||||||
|
export default function Messagerie() {
|
||||||
|
const [selectedConversation, setSelectedConversation] = useState<string | null>(null);
|
||||||
|
const [showNewConversation, setShowNewConversation] = useState(false);
|
||||||
|
const [showGroupSettings, setShowGroupSettings] = useState(false);
|
||||||
|
|
||||||
|
const { data: conversations, error, mutate } = useSWR<Conversation[]>(
|
||||||
|
'/api/conversations',
|
||||||
|
fetcher,
|
||||||
|
{
|
||||||
|
refreshInterval: 2000, // Rafraîchir toutes les 2 secondes
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedConv = conversations?.find((c) => c.id === selectedConversation);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Sélectionner automatiquement la première conversation si aucune n'est sélectionnée
|
||||||
|
if (!selectedConversation && conversations && conversations.length > 0) {
|
||||||
|
setSelectedConversation(conversations[0].id);
|
||||||
|
}
|
||||||
|
}, [conversations, selectedConversation]);
|
||||||
|
|
||||||
|
const handleNewMessage = () => {
|
||||||
|
// Rafraîchir la liste des conversations quand un nouveau message arrive
|
||||||
|
mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-80px)] bg-white">
|
||||||
|
{/* Liste des conversations */}
|
||||||
|
<div className="w-80 border-r border-gray-200 flex flex-col">
|
||||||
|
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Messages</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewConversation(true)}
|
||||||
|
className="p-2 rounded-lg bg-lblue text-white hover:bg-dblue transition-colors"
|
||||||
|
title="Nouvelle conversation"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 text-sm text-red-600">
|
||||||
|
Erreur lors du chargement des conversations
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!conversations && !error && (
|
||||||
|
<div className="p-4 text-sm text-gray-500 text-center">Chargement...</div>
|
||||||
|
)}
|
||||||
|
{conversations && conversations.length === 0 && (
|
||||||
|
<div className="p-4 text-sm text-gray-500 text-center">
|
||||||
|
Aucune conversation. Créez-en une nouvelle !
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{conversations && conversations.length > 0 && (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{conversations.map((conversation) => (
|
||||||
|
<button
|
||||||
|
key={conversation.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedConversation(conversation.id);
|
||||||
|
if (conversation.type === 'group') {
|
||||||
|
setShowGroupSettings(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`w-full p-4 text-left hover:bg-gray-50 transition-colors ${
|
||||||
|
selectedConversation === conversation.id ? 'bg-lblue/10' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-lblue to-dblue flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-white font-semibold text-sm">
|
||||||
|
{conversation.type === 'group'
|
||||||
|
? conversation.displayName.charAt(0).toUpperCase()
|
||||||
|
: conversation.displayName
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n.charAt(0))
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 truncate">
|
||||||
|
{conversation.displayName}
|
||||||
|
</h3>
|
||||||
|
{conversation.unreadCount > 0 && (
|
||||||
|
<span className="flex-shrink-0 w-5 h-5 bg-red-500 text-white text-xs font-semibold rounded-full flex items-center justify-center">
|
||||||
|
{conversation.unreadCount > 99 ? '99+' : conversation.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{conversation.lastMessage && (
|
||||||
|
<span className="text-xs text-gray-500 flex-shrink-0 ml-2">
|
||||||
|
{new Date(conversation.lastMessage.createdAt).toLocaleTimeString('fr-FR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{conversation.lastMessage ? (
|
||||||
|
<p className="text-sm text-gray-600 truncate">
|
||||||
|
{conversation.type === 'group' && conversation.lastMessage.senderName
|
||||||
|
? `${conversation.lastMessage.senderName}: `
|
||||||
|
: ''}
|
||||||
|
{conversation.lastMessage.hasFiles && !conversation.lastMessage.content
|
||||||
|
? '📎 Fichier'
|
||||||
|
: conversation.lastMessage.content || '📎 Fichier'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 italic">Aucun message</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fenêtre de chat */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{selectedConversation && selectedConv ? (
|
||||||
|
<ChatWindow
|
||||||
|
conversationId={selectedConversation}
|
||||||
|
conversation={selectedConv}
|
||||||
|
onNewMessage={handleNewMessage}
|
||||||
|
onShowGroupSettings={() => setShowGroupSettings(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg
|
||||||
|
className="w-16 h-16 text-gray-400 mx-auto mb-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-gray-500">Sélectionnez une conversation pour commencer</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modales */}
|
||||||
|
{showNewConversation && (
|
||||||
|
<NewConversationModal
|
||||||
|
onClose={() => setShowNewConversation(false)}
|
||||||
|
onConversationCreated={(conversationId) => {
|
||||||
|
setSelectedConversation(conversationId);
|
||||||
|
setShowNewConversation(false);
|
||||||
|
mutate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showGroupSettings && selectedConv && selectedConv.type === 'group' && (
|
||||||
|
<GroupSettingsModal
|
||||||
|
conversation={selectedConv}
|
||||||
|
onClose={() => setShowGroupSettings(false)}
|
||||||
|
onUpdate={mutate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
components/NewConversationModal.tsx
Normal file
239
components/NewConversationModal.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewConversationModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onConversationCreated: (conversationId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||||
|
|
||||||
|
export default function NewConversationModal({
|
||||||
|
onClose,
|
||||||
|
onConversationCreated,
|
||||||
|
}: NewConversationModalProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
|
const [conversationType, setConversationType] = useState<'direct' | 'group'>('direct');
|
||||||
|
const [groupName, setGroupName] = useState('');
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
// Récupérer l'utilisateur actuel
|
||||||
|
const { data: currentUser } = useSWR<User>('/api/auth/me', fetcher);
|
||||||
|
|
||||||
|
const { data: users, error } = useSWR<User[]>(
|
||||||
|
search ? `/api/users?search=${encodeURIComponent(search)}` : '/api/users',
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filtrer l'utilisateur actuel de la liste des utilisateurs
|
||||||
|
const availableUsers = users?.filter((user) => user.id !== currentUser?.id) || [];
|
||||||
|
|
||||||
|
const handleUserToggle = (userId: string) => {
|
||||||
|
if (selectedUsers.includes(userId)) {
|
||||||
|
setSelectedUsers(selectedUsers.filter((id) => id !== userId));
|
||||||
|
} else {
|
||||||
|
setSelectedUsers([...selectedUsers, userId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (selectedUsers.length === 0) {
|
||||||
|
alert('Veuillez sélectionner au moins un utilisateur');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conversationType === 'group' && !groupName.trim()) {
|
||||||
|
alert('Veuillez entrer un nom pour le groupe');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/conversations', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
participantIds: selectedUsers,
|
||||||
|
type: conversationType,
|
||||||
|
name: conversationType === 'group' ? groupName : null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const conversation = await response.json();
|
||||||
|
onConversationCreated(conversation.id);
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Erreur lors de la création de la conversation');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur:', error);
|
||||||
|
alert('Erreur lors de la création de la conversation');
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||||
|
<div className="p-6 border-b border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">Nouvelle conversation</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{/* Type de conversation */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Type de conversation
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value="direct"
|
||||||
|
checked={conversationType === 'direct'}
|
||||||
|
onChange={(e) => {
|
||||||
|
setConversationType(e.target.value as 'direct');
|
||||||
|
setGroupName('');
|
||||||
|
}}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Directe</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value="group"
|
||||||
|
checked={conversationType === 'group'}
|
||||||
|
onChange={(e) => setConversationType(e.target.value as 'group')}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Groupe</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nom du groupe */}
|
||||||
|
{conversationType === 'group' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Nom du groupe
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={groupName}
|
||||||
|
onChange={(e) => setGroupName(e.target.value)}
|
||||||
|
placeholder="Entrez le nom du groupe"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-lblue focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recherche d'utilisateurs */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Rechercher des utilisateurs
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Rechercher par nom ou email..."
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-lblue focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des utilisateurs */}
|
||||||
|
<div className="max-h-64 overflow-y-auto border border-gray-200 rounded-lg">
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 text-sm text-red-600">
|
||||||
|
Erreur lors du chargement des utilisateurs
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!users && !error && (
|
||||||
|
<div className="p-4 text-sm text-gray-500 text-center">Chargement...</div>
|
||||||
|
)}
|
||||||
|
{availableUsers.length === 0 && users && (
|
||||||
|
<div className="p-4 text-sm text-gray-500 text-center">Aucun utilisateur trouvé</div>
|
||||||
|
)}
|
||||||
|
{availableUsers.length > 0 && (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{availableUsers.map((user) => (
|
||||||
|
<label
|
||||||
|
key={user.id}
|
||||||
|
className="flex items-center gap-3 p-3 hover:bg-gray-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedUsers.includes(user.id)}
|
||||||
|
onChange={() => handleUserToggle(user.id)}
|
||||||
|
className="rounded border-gray-300 text-lblue focus:ring-lblue"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{user.name || 'Utilisateur'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Utilisateurs sélectionnés */}
|
||||||
|
{selectedUsers.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{selectedUsers.length} utilisateur{selectedUsers.length > 1 ? 's' : ''} sélectionné{selectedUsers.length > 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 border-t border-gray-200 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isCreating || selectedUsers.length === 0}
|
||||||
|
className="px-4 py-2 bg-lblue text-white rounded-lg hover:bg-dblue transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isCreating ? 'Création...' : 'Créer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
components/NotificationProvider.tsx
Normal file
65
components/NotificationProvider.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||||
|
import NotificationToast from './NotificationToast';
|
||||||
|
|
||||||
|
export type NotificationType = 'success' | 'error' | 'info' | 'warning';
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
type: NotificationType;
|
||||||
|
message: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationContextType {
|
||||||
|
showNotification: (type: NotificationType, message: string, duration?: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function useNotification() {
|
||||||
|
const context = useContext(NotificationContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useNotification must be used within NotificationProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
|
||||||
|
const showNotification = useCallback(
|
||||||
|
(type: NotificationType, message: string, duration = 4000) => {
|
||||||
|
const id = Math.random().toString(36).substring(2, 9);
|
||||||
|
const notification: Notification = { id, type, message, duration };
|
||||||
|
|
||||||
|
setNotifications((prev) => [...prev, notification]);
|
||||||
|
|
||||||
|
// Auto-remove after duration
|
||||||
|
setTimeout(() => {
|
||||||
|
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||||
|
}, duration);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeNotification = useCallback((id: string) => {
|
||||||
|
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationContext.Provider value={{ showNotification }}>
|
||||||
|
{children}
|
||||||
|
<div className="fixed top-20 right-4 z-50 flex flex-col gap-2 max-w-md w-full">
|
||||||
|
{notifications.map((notification) => (
|
||||||
|
<NotificationToast
|
||||||
|
key={notification.id}
|
||||||
|
notification={notification}
|
||||||
|
onClose={() => removeNotification(notification.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</NotificationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
components/NotificationToast.tsx
Normal file
73
components/NotificationToast.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Notification } from './NotificationProvider';
|
||||||
|
|
||||||
|
interface NotificationToastProps {
|
||||||
|
notification: Notification;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationToast({ notification, onClose }: NotificationToastProps) {
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (notification.type) {
|
||||||
|
case 'success':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'error':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'warning':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = () => {
|
||||||
|
switch (notification.type) {
|
||||||
|
case 'success':
|
||||||
|
return 'bg-green-50 border-green-200 text-green-800';
|
||||||
|
case 'error':
|
||||||
|
return 'bg-red-50 border-red-200 text-red-800';
|
||||||
|
case 'warning':
|
||||||
|
return 'bg-yellow-50 border-yellow-200 text-yellow-800';
|
||||||
|
default:
|
||||||
|
return 'bg-blue-50 border-blue-200 text-blue-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${getStyles()} border rounded-lg shadow-lg p-4 flex items-start gap-3 animate-slideInRight min-w-[300px]`}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 mt-0.5">{getIcon()}</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{notification.message}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import TrajetForm from './TrajetForm';
|
import TrajetForm from './TrajetForm';
|
||||||
import ValidationModal from './ValidationModal';
|
import ValidationModal from './ValidationModal';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
import { useNotification } from './NotificationProvider';
|
||||||
|
|
||||||
interface Trajet {
|
interface Trajet {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,8 +35,11 @@ interface TrajetDetailModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TrajetDetailModal({ trajet, onClose, onUpdate }: TrajetDetailModalProps) {
|
export default function TrajetDetailModal({ trajet, onClose, onUpdate }: TrajetDetailModalProps) {
|
||||||
|
const { showNotification } = useNotification();
|
||||||
const [showEditForm, setShowEditForm] = useState(false);
|
const [showEditForm, setShowEditForm] = useState(false);
|
||||||
const [showValidationModal, setShowValidationModal] = useState(false);
|
const [showValidationModal, setShowValidationModal] = useState(false);
|
||||||
|
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
|
||||||
|
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
@@ -52,11 +57,12 @@ export default function TrajetDetailModal({ trajet, onClose, onUpdate }: TrajetD
|
|||||||
return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = async () => {
|
const handleCancelClick = () => {
|
||||||
if (!confirm('Êtes-vous sûr de vouloir annuler ce trajet ?')) {
|
setShowCancelConfirm(true);
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
setShowCancelConfirm(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/trajets/${trajet.id}`, {
|
const response = await fetch(`/api/trajets/${trajet.id}`, {
|
||||||
@@ -70,15 +76,47 @@ export default function TrajetDetailModal({ trajet, onClose, onUpdate }: TrajetD
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
showNotification('info', 'Trajet annulé avec succès');
|
||||||
onUpdate();
|
onUpdate();
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.error || 'Erreur lors de l\'annulation du trajet');
|
showNotification('error', error.error || 'Erreur lors de l\'annulation du trajet');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de l\'annulation:', error);
|
console.error('Erreur lors de l\'annulation:', error);
|
||||||
alert('Erreur lors de l\'annulation du trajet');
|
showNotification('error', 'Erreur lors de l\'annulation du trajet');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchiveClick = () => {
|
||||||
|
setShowArchiveConfirm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async () => {
|
||||||
|
setShowArchiveConfirm(false);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/trajets/${trajet.id}/archive`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showNotification('info', 'Trajet archivé avec succès');
|
||||||
|
onUpdate();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
showNotification('error', error.error || 'Erreur lors de l\'archivage du trajet');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de l\'archivage:', error);
|
||||||
|
showNotification('error', 'Erreur lors de l\'archivage du trajet');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -274,7 +312,7 @@ export default function TrajetDetailModal({ trajet, onClose, onUpdate }: TrajetD
|
|||||||
<div className="flex justify-between gap-3">
|
<div className="flex justify-between gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCancel}
|
onClick={handleCancelClick}
|
||||||
disabled={loading || trajet.statut === 'Validé' || trajet.statut === 'Terminé' || trajet.statut === 'Annulé'}
|
disabled={loading || trajet.statut === 'Validé' || trajet.statut === 'Terminé' || trajet.statut === 'Annulé'}
|
||||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
className="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@@ -287,7 +325,7 @@ export default function TrajetDetailModal({ trajet, onClose, onUpdate }: TrajetD
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowEditForm(true)}
|
onClick={() => setShowEditForm(true)}
|
||||||
disabled={trajet.statut === 'Validé' || trajet.statut === 'Terminé'}
|
disabled={trajet.statut === 'Validé' || trajet.statut === 'Terminé' || trajet.statut === 'Annulé'}
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -295,6 +333,17 @@ export default function TrajetDetailModal({ trajet, onClose, onUpdate }: TrajetD
|
|||||||
</svg>
|
</svg>
|
||||||
Modifier
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleArchiveClick}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-orange-600 hover:text-orange-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||||
|
</svg>
|
||||||
|
Archiver
|
||||||
|
</button>
|
||||||
{trajet.chauffeur && trajet.statut === 'Planifié' && (
|
{trajet.chauffeur && trajet.statut === 'Planifié' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -310,6 +359,27 @@ export default function TrajetDetailModal({ trajet, onClose, onUpdate }: TrajetD
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={showCancelConfirm}
|
||||||
|
title="Annuler le trajet"
|
||||||
|
message="Êtes-vous sûr de vouloir annuler ce trajet ?"
|
||||||
|
confirmText="Annuler le trajet"
|
||||||
|
cancelText="Retour"
|
||||||
|
confirmColor="danger"
|
||||||
|
onConfirm={handleCancel}
|
||||||
|
onCancel={() => setShowCancelConfirm(false)}
|
||||||
|
/>
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={showArchiveConfirm}
|
||||||
|
title="Archiver le trajet"
|
||||||
|
message="Êtes-vous sûr de vouloir archiver ce trajet ? Il ne sera plus visible dans le calendrier."
|
||||||
|
confirmText="Archiver"
|
||||||
|
cancelText="Annuler"
|
||||||
|
confirmColor="warning"
|
||||||
|
onConfirm={handleArchive}
|
||||||
|
onCancel={() => setShowArchiveConfirm(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import TrajetMap from './TrajetMap';
|
import TrajetMap from './TrajetMap';
|
||||||
import AddressAutocomplete from './AddressAutocomplete';
|
import AddressAutocomplete from './AddressAutocomplete';
|
||||||
|
import { useNotification } from './NotificationProvider';
|
||||||
|
|
||||||
interface Adherent {
|
interface Adherent {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,6 +38,7 @@ interface TrajetFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TrajetForm({ onClose, onSuccess, trajetToEdit }: TrajetFormProps) {
|
export default function TrajetForm({ onClose, onSuccess, trajetToEdit }: TrajetFormProps) {
|
||||||
|
const { showNotification } = useNotification();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [adherents, setAdherents] = useState<Adherent[]>([]);
|
const [adherents, setAdherents] = useState<Adherent[]>([]);
|
||||||
const [chauffeurs, setChauffeurs] = useState<Chauffeur[]>([]);
|
const [chauffeurs, setChauffeurs] = useState<Chauffeur[]>([]);
|
||||||
@@ -233,15 +235,19 @@ export default function TrajetForm({ onClose, onSuccess, trajetToEdit }: TrajetF
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
showNotification(
|
||||||
|
'success',
|
||||||
|
trajetToEdit ? 'Trajet modifié avec succès' : 'Trajet créé avec succès'
|
||||||
|
);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(`Erreur: ${error.error || `Erreur lors de la ${trajetToEdit ? 'modification' : 'création'} du trajet`}`);
|
showNotification('error', error.error || `Erreur lors de la ${trajetToEdit ? 'modification' : 'création'} du trajet`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Erreur lors de la ${trajetToEdit ? 'modification' : 'création'} du trajet:`, error);
|
console.error(`Erreur lors de la ${trajetToEdit ? 'modification' : 'création'} du trajet:`, error);
|
||||||
alert(`Erreur lors de la ${trajetToEdit ? 'modification' : 'création'} du trajet`);
|
showNotification('error', `Erreur lors de la ${trajetToEdit ? 'modification' : 'création'} du trajet`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNotification } from './NotificationProvider';
|
||||||
|
|
||||||
interface Trajet {
|
interface Trajet {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -21,6 +22,7 @@ interface ValidationModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ValidationModal({ trajet, onClose, onSuccess }: ValidationModalProps) {
|
export default function ValidationModal({ trajet, onClose, onSuccess }: ValidationModalProps) {
|
||||||
|
const { showNotification } = useNotification();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [dureeTrajet, setDureeTrajet] = useState<number | null>(null);
|
const [dureeTrajet, setDureeTrajet] = useState<number | null>(null);
|
||||||
|
|
||||||
@@ -103,8 +105,12 @@ export default function ValidationModal({ trajet, onClose, onSuccess }: Validati
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleValidate = async () => {
|
const handleValidate = async () => {
|
||||||
if (!trajet.chauffeur || !dureeTrajet) {
|
if (!trajet.chauffeur) {
|
||||||
alert('Impossible de calculer la durée du trajet');
|
showNotification('warning', 'Aucun chauffeur assigné à ce trajet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!dureeTrajet) {
|
||||||
|
showNotification('warning', 'Impossible de calculer la durée du trajet');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,15 +127,16 @@ export default function ValidationModal({ trajet, onClose, onSuccess }: Validati
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
showNotification('success', `Trajet validé avec succès (${dureeTrajet}h déduites)`);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.error || 'Erreur lors de la validation du trajet');
|
showNotification('error', error.error || 'Erreur lors de la validation du trajet');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la validation:', error);
|
console.error('Erreur lors de la validation:', error);
|
||||||
alert('Erreur lors de la validation du trajet');
|
showNotification('error', 'Erreur lors de la validation du trajet');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
46
package-lock.json
generated
46
package-lock.json
generated
@@ -19,7 +19,8 @@
|
|||||||
"next-auth": "^4.24.7",
|
"next-auth": "^4.24.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-leaflet": "^4.2.1"
|
"react-leaflet": "^4.2.1",
|
||||||
|
"swr": "^2.3.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
@@ -1037,14 +1038,14 @@
|
|||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
|
||||||
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
|
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines": {
|
"node_modules/@prisma/engines": {
|
||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
|
||||||
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
|
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1058,14 +1059,14 @@
|
|||||||
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
|
||||||
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
|
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/fetch-engine": {
|
"node_modules/@prisma/fetch-engine": {
|
||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
|
||||||
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
|
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "5.22.0",
|
"@prisma/debug": "5.22.0",
|
||||||
@@ -1077,7 +1078,7 @@
|
|||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
|
||||||
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
|
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "5.22.0"
|
"@prisma/debug": "5.22.0"
|
||||||
@@ -2442,6 +2443,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dequal": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/didyoumean": {
|
"node_modules/didyoumean": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||||
@@ -5354,7 +5364,7 @@
|
|||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
||||||
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6232,6 +6242,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/swr": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dequal": "^2.0.3",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.19",
|
"version": "3.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
@@ -6650,6 +6673,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
"next-auth": "^4.24.7",
|
"next-auth": "^4.24.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-leaflet": "^4.2.1"
|
"react-leaflet": "^4.2.1",
|
||||||
|
"swr": "^2.3.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
|||||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
@@ -17,6 +17,8 @@ model User {
|
|||||||
name String?
|
name String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
conversations ConversationParticipant[]
|
||||||
|
sentMessages Message[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Chauffeur {
|
model Chauffeur {
|
||||||
@@ -75,7 +77,8 @@ model Trajet {
|
|||||||
adresseDepart String // Adresse de départ
|
adresseDepart String // Adresse de départ
|
||||||
adresseArrivee String // Adresse d'arrivée
|
adresseArrivee String // Adresse d'arrivée
|
||||||
commentaire String? // Commentaire optionnel
|
commentaire String? // Commentaire optionnel
|
||||||
statut String @default("Planifié") // Planifié, En cours, Terminé, Annulé
|
statut String @default("Planifié") // Planifié, En cours, Terminé, Annulé, Validé
|
||||||
|
archived Boolean @default(false) // Indique si le trajet est archivé
|
||||||
adherentId String // Référence à l'adhérent
|
adherentId String // Référence à l'adhérent
|
||||||
adherent Adherent @relation(fields: [adherentId], references: [id])
|
adherent Adherent @relation(fields: [adherentId], references: [id])
|
||||||
chauffeurId String? // Référence au chauffeur (optionnel)
|
chauffeurId String? // Référence au chauffeur (optionnel)
|
||||||
@@ -83,3 +86,57 @@ model Trajet {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Conversation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String? // Nom pour les groupes (null pour les conversations individuelles)
|
||||||
|
type String @default("direct") // "direct" ou "group"
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
participants ConversationParticipant[]
|
||||||
|
messages Message[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model ConversationParticipant {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
conversationId String
|
||||||
|
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
joinedAt DateTime @default(now())
|
||||||
|
lastReadAt DateTime? // Dernière fois que l'utilisateur a lu les messages
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([conversationId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([conversationId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Message {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
conversationId String
|
||||||
|
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||||
|
senderId String
|
||||||
|
sender User @relation(fields: [senderId], references: [id])
|
||||||
|
content String? // Contenu textuel du message (peut être null si seulement des fichiers)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
files MessageFile[]
|
||||||
|
|
||||||
|
@@index([conversationId, createdAt])
|
||||||
|
@@index([senderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model MessageFile {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
messageId String
|
||||||
|
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||||
|
filename String // Nom original du fichier
|
||||||
|
filepath String // Chemin de stockage du fichier
|
||||||
|
fileType String // Type MIME du fichier
|
||||||
|
fileSize Int // Taille en bytes
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([messageId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,31 +6,65 @@ const prisma = new PrismaClient();
|
|||||||
async function main() {
|
async function main() {
|
||||||
console.log('🌱 Initialisation de la base de données...');
|
console.log('🌱 Initialisation de la base de données...');
|
||||||
|
|
||||||
// Créer un utilisateur de test
|
// Créer le premier utilisateur de test
|
||||||
const email = 'admin@example.com';
|
const email1 = 'admin@example.com';
|
||||||
const password = 'admin123';
|
const password1 = 'admin123';
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const hashedPassword1 = await bcrypt.hash(password1, 10);
|
||||||
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
const existingUser1 = await prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email: email1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser1) {
|
||||||
console.log('✅ Utilisateur de test existe déjà');
|
console.log('✅ Utilisateur 1 existe déjà');
|
||||||
console.log(` Email: ${email}`);
|
console.log(` Email: ${email1}`);
|
||||||
console.log(` Mot de passe: ${password}`);
|
console.log(` Mot de passe: ${password1}`);
|
||||||
} else {
|
} else {
|
||||||
const user = await prisma.user.create({
|
const user1 = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email: email1,
|
||||||
password: hashedPassword,
|
password: hashedPassword1,
|
||||||
name: 'Administrateur',
|
name: 'Administrateur',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log('✅ Utilisateur de test créé');
|
console.log('✅ Utilisateur 1 créé');
|
||||||
console.log(` Email: ${email}`);
|
console.log(` Email: ${email1}`);
|
||||||
console.log(` Mot de passe: ${password}`);
|
console.log(` Mot de passe: ${password1}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Créer le deuxième utilisateur de test
|
||||||
|
const email2 = 'chauffeur@example.com';
|
||||||
|
const password2 = 'chauffeur123';
|
||||||
|
const hashedPassword2 = await bcrypt.hash(password2, 10);
|
||||||
|
|
||||||
|
const existingUser2 = await prisma.user.findUnique({
|
||||||
|
where: { email: email2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser2) {
|
||||||
|
console.log('✅ Utilisateur 2 existe déjà');
|
||||||
|
console.log(` Email: ${email2}`);
|
||||||
|
console.log(` Mot de passe: ${password2}`);
|
||||||
|
} else {
|
||||||
|
const user2 = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: email2,
|
||||||
|
password: hashedPassword2,
|
||||||
|
name: 'Chauffeur Test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ Utilisateur 2 créé');
|
||||||
|
console.log(` Email: ${email2}`);
|
||||||
|
console.log(` Mot de passe: ${password2}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n📝 Comptes de test disponibles:');
|
||||||
|
console.log(' Compte 1:');
|
||||||
|
console.log(` Email: ${email1}`);
|
||||||
|
console.log(` Mot de passe: ${password1}`);
|
||||||
|
console.log(' Compte 2:');
|
||||||
|
console.log(` Email: ${email2}`);
|
||||||
|
console.log(` Mot de passe: ${password2}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user