Added Chat Page

This commit is contained in:
2026-01-21 18:13:35 +01:00
parent 0ca8ce8b52
commit 3eed79ca93
37 changed files with 3966 additions and 64 deletions

View 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>
);
}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, useDraggable, useDroppable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import TrajetDetailModal from './TrajetDetailModal';
import { useNotification } from './NotificationProvider';
interface Trajet {
id: string;
@@ -152,6 +153,7 @@ function DroppableDayCell({
}
export default function CalendrierTrajets({ refreshTrigger }: CalendrierTrajetsProps) {
const { showNotification } = useNotification();
const [trajets, setTrajets] = useState<Trajet[]>([]);
const [loading, setLoading] = useState(true);
const [currentDate, setCurrentDate] = useState(new Date());
@@ -308,15 +310,21 @@ export default function CalendrierTrajets({ refreshTrigger }: CalendrierTrajetsP
});
if (response.ok) {
const targetDateFormatted = new Date(newDate).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
showNotification('success', `Trajet déplacé vers le ${targetDateFormatted}`);
// Utiliser fetchTrajets sans loading pour éviter de vider les trajets pendant le chargement
await fetchTrajets(false);
} else {
const error = await response.json();
alert(error.error || 'Erreur lors du déplacement du trajet');
showNotification('error', error.error || 'Erreur lors du déplacement du trajet');
}
} catch (error) {
console.error('Erreur lors du déplacement:', error);
alert('Erreur lors du déplacement du trajet');
showNotification('error', 'Erreur lors du déplacement du trajet');
}
}
};

589
components/ChatWindow.tsx Normal file
View 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>
);
}

View 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>
);
}

View File

@@ -4,6 +4,7 @@ import { useRouter, usePathname } from 'next/navigation';
import { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import useSWR from 'swr';
interface User {
id: string;
@@ -22,10 +23,37 @@ interface NavItem {
icon: React.ReactNode;
}
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export default function DashboardLayout({ user, children }: DashboardLayoutProps) {
const router = useRouter();
const pathname = usePathname();
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[] = [
{
@@ -78,9 +106,11 @@ export default function DashboardLayout({ user, children }: DashboardLayoutProps
label: 'Messagerie',
href: '/dashboard/messagerie',
icon: (
<svg className="w-6 h-6" 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>
<div className="relative">
<svg className="w-6 h-6" 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>
</div>
),
},
{
@@ -92,6 +122,15 @@ export default function DashboardLayout({ user, children }: DashboardLayoutProps
</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 () => {
@@ -131,22 +170,28 @@ export default function DashboardLayout({ user, children }: DashboardLayoutProps
<ul className="space-y-1">
{navItems.map((item) => {
const isActive = pathname === item.href;
const isMessagerie = item.href === '/dashboard/messagerie';
const showBadge = isMessagerie && totalUnreadCount > 0;
return (
<li key={item.href}>
<Link
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
? 'bg-lblue text-white'
: 'text-gray-700 hover:bg-lblue/10'
}`}
>
<span className={isActive ? 'text-white' : 'text-lblue'}>
{item.icon}
</span>
<span className={`text-sm font-medium ${isActive ? '' : ''}`}>
<span className={isActive ? 'text-white' : 'text-lblue'}>{item.icon}</span>
<span className={`text-sm font-medium flex-1 ${isActive ? '' : ''}`}>
{item.label}
</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>
</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">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-gray-900"></h1>
<div className="flex items-center gap-4">
{/* Notification Icon */}
<button className="relative w-10 h-10 rounded-full bg-[#6B46C1] flex items-center justify-center hover:bg-[#5B21B6] transition-colors">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 bg-red-500 rounded-full border-2 border-white"></span>
</button>
<div className="flex items-center gap-3">
{/* Notification Button */}
<div className="relative">
<button
onClick={() => setShowNotifications(!showNotifications)}
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"
>
<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 */}
<button className="w-10 h-10 rounded-full bg-[#6B46C1] flex items-center justify-center hover:bg-[#5B21B6] transition-colors">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
</svg>
</button>
<div className="relative">
<button
onClick={() => setShowProfileMenu(!showProfileMenu)}
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"
>
<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>
</header>
@@ -192,6 +317,17 @@ export default function DashboardLayout({ user, children }: DashboardLayoutProps
{children}
</main>
</div>
{/* Overlay pour fermer les menus */}
{(showNotifications || showProfileMenu) && (
<div
className="fixed inset-0 z-40"
onClick={() => {
setShowNotifications(false);
setShowProfileMenu(false);
}}
/>
)}
</div>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -3,6 +3,8 @@
import { useState } from 'react';
import TrajetForm from './TrajetForm';
import ValidationModal from './ValidationModal';
import ConfirmModal from './ConfirmModal';
import { useNotification } from './NotificationProvider';
interface Trajet {
id: string;
@@ -33,8 +35,11 @@ interface TrajetDetailModalProps {
}
export default function TrajetDetailModal({ trajet, onClose, onUpdate }: TrajetDetailModalProps) {
const { showNotification } = useNotification();
const [showEditForm, setShowEditForm] = useState(false);
const [showValidationModal, setShowValidationModal] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
const [loading, setLoading] = useState(false);
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' });
};
const handleCancel = async () => {
if (!confirm('Êtes-vous sûr de vouloir annuler ce trajet ?')) {
return;
}
const handleCancelClick = () => {
setShowCancelConfirm(true);
};
const handleCancel = async () => {
setShowCancelConfirm(false);
setLoading(true);
try {
const response = await fetch(`/api/trajets/${trajet.id}`, {
@@ -70,15 +76,47 @@ export default function TrajetDetailModal({ trajet, onClose, onUpdate }: TrajetD
});
if (response.ok) {
showNotification('info', 'Trajet annulé avec succès');
onUpdate();
onClose();
} else {
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) {
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 {
setLoading(false);
}
@@ -274,7 +312,7 @@ export default function TrajetDetailModal({ trajet, onClose, onUpdate }: TrajetD
<div className="flex justify-between gap-3">
<button
type="button"
onClick={handleCancel}
onClick={handleCancelClick}
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"
>
@@ -287,7 +325,7 @@ export default function TrajetDetailModal({ trajet, onClose, onUpdate }: TrajetD
<button
type="button"
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"
>
<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>
Modifier
</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é' && (
<button
type="button"
@@ -310,6 +359,27 @@ export default function TrajetDetailModal({ trajet, onClose, onUpdate }: TrajetD
</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>
);

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from 'react';
import TrajetMap from './TrajetMap';
import AddressAutocomplete from './AddressAutocomplete';
import { useNotification } from './NotificationProvider';
interface Adherent {
id: string;
@@ -37,6 +38,7 @@ interface TrajetFormProps {
}
export default function TrajetForm({ onClose, onSuccess, trajetToEdit }: TrajetFormProps) {
const { showNotification } = useNotification();
const [loading, setLoading] = useState(false);
const [adherents, setAdherents] = useState<Adherent[]>([]);
const [chauffeurs, setChauffeurs] = useState<Chauffeur[]>([]);
@@ -233,15 +235,19 @@ export default function TrajetForm({ onClose, onSuccess, trajetToEdit }: TrajetF
});
if (response.ok) {
showNotification(
'success',
trajetToEdit ? 'Trajet modifié avec succès' : 'Trajet créé avec succès'
);
onSuccess();
onClose();
} else {
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) {
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 {
setLoading(false);
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useNotification } from './NotificationProvider';
interface Trajet {
id: string;
@@ -21,6 +22,7 @@ interface ValidationModalProps {
}
export default function ValidationModal({ trajet, onClose, onSuccess }: ValidationModalProps) {
const { showNotification } = useNotification();
const [loading, setLoading] = useState(false);
const [dureeTrajet, setDureeTrajet] = useState<number | null>(null);
@@ -103,8 +105,12 @@ export default function ValidationModal({ trajet, onClose, onSuccess }: Validati
}, []);
const handleValidate = async () => {
if (!trajet.chauffeur || !dureeTrajet) {
alert('Impossible de calculer la durée du trajet');
if (!trajet.chauffeur) {
showNotification('warning', 'Aucun chauffeur assigné à ce trajet');
return;
}
if (!dureeTrajet) {
showNotification('warning', 'Impossible de calculer la durée du trajet');
return;
}
@@ -121,15 +127,16 @@ export default function ValidationModal({ trajet, onClose, onSuccess }: Validati
});
if (response.ok) {
showNotification('success', `Trajet validé avec succès (${dureeTrajet}h déduites)`);
onSuccess();
onClose();
} else {
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) {
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 {
setLoading(false);
}