feat: Messagerie WIP [#17]

This commit is contained in:
Luc SORIGNET
2025-05-11 14:02:04 +02:00
parent c6d75281a1
commit 23a593dbc7
28 changed files with 1177 additions and 391 deletions

View File

@ -0,0 +1,52 @@
'use client';
import React, { useState } from 'react';
export default function AnnouncementScheduler({ csrfToken }) {
const [title, setTitle] = useState('');
const [date, setDate] = useState('');
const [message, setMessage] = useState('');
const handleSchedule = () => {
// Logique pour planifier une annonce
console.log('Annonce planifiée:', { title, date, message });
};
return (
<div className="p-4 bg-white rounded shadow">
<h2 className="text-xl font-bold mb-4">Planifier une Annonce</h2>
<div className="mb-4">
<label className="block font-medium">Titre</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full p-2 border rounded"
/>
</div>
<div className="mb-4">
<label className="block font-medium">Date</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full p-2 border rounded"
/>
</div>
<div className="mb-4">
<label className="block font-medium">Message</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full p-2 border rounded"
rows="5"
/>
</div>
<button
onClick={handleSchedule}
className="bg-blue-500 text-white px-4 py-2 rounded"
>
Planifier
</button>
</div>
);
}

View File

@ -1,75 +1,163 @@
'use client';
import React, { useState } from 'react';
import { Editor } from '@tinymce/tinymce-react';
import { sendMessage } from '@/app/actions/messagerieAction';
import React, { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { sendMessage, searchRecipients } from '@/app/actions/messagerieAction';
import { fetchSmtpSettings } from '@/app/actions/settingsAction';
import { useNotification } from '@/context/NotificationContext';
import { useEstablishment } from '@/context/EstablishmentContext';
import AlertMessage from '@/components/AlertMessage';
import RecipientInput from '@/components/RecipientInput';
// Charger Quill dynamiquement pour éviter les problèmes de SSR
const ReactQuill = dynamic(() => import('react-quill'), { ssr: false });
import 'react-quill/dist/quill.snow.css'; // Importer les styles de Quill
export default function EmailSender({ csrfToken }) {
const [recipients, setRecipients] = useState('');
const [recipients, setRecipients] = useState([]);
const [fromEmail, setFromEmail] = useState('');
const [cc, setCc] = useState([]);
const [bcc, setBcc] = useState([]);
const [subject, setSubject] = useState('');
const [message, setMessage] = useState('');
const [status, setStatus] = useState('');
const [smtpConfigured, setSmtpConfigured] = useState(false); // État pour vérifier si SMTP est configuré
const { showNotification } = useNotification();
const { selectedEstablishmentId } = useEstablishment(); // Récupérer l'establishment_id depuis le contexte
useEffect(() => {
// Vérifier si les paramètres SMTP sont configurés
fetchSmtpSettings(csrfToken, selectedEstablishmentId)
.then((data) => {
if (data.smtp_server && data.smtp_port && data.smtp_user) {
setFromEmail(data.smtp_user);
setSmtpConfigured(true);
} else {
setSmtpConfigured(false);
}
})
.catch((error) => {
console.error(
'Erreur lors de la vérification des paramètres SMTP:',
error
);
setSmtpConfigured(false);
});
}, [csrfToken, selectedEstablishmentId]);
const handleSendEmail = async () => {
const data = {
recipients: recipients.split(',').map((email) => email.trim()),
recipients,
cc,
bcc,
subject,
message,
establishment_id: selectedEstablishmentId, // Ajouter l'establishment_id à la payload
};
sendMessage(data);
try {
await sendMessage(data);
showNotification('Email envoyé avec succès.', 'success', 'Succès');
// Réinitialiser les champs après succès
setRecipients([]);
setCc([]);
setBcc([]);
setSubject('');
setMessage('');
} catch (error) {
console.error("Erreur lors de l'envoi de l'email:", error);
showNotification(
"Une erreur est survenue lors de l'envoi de l'email.",
'error',
'Erreur'
);
}
};
if (!smtpConfigured) {
return (
<AlertMessage
type="warning"
title="Configuration SMTP requise"
message="Les paramètres SMTP de cet établissement ne sont pas configurés. Veuillez les configurer dans la page des paramètres."
actionLabel="Aller aux paramètres"
onAction={() => (window.location.href = '/admin/settings')} // Redirige vers la page des paramètres
/>
);
}
return (
<div className="p-4 bg-white rounded shadow">
<h2 className="text-xl font-bold mb-4">Envoyer un Email</h2>
<div className="mb-4">
<label className="block font-medium">
Destinataires (séparés par des virgules)
</label>
<input
type="text"
value={recipients}
onChange={(e) => setRecipients(e.target.value)}
className="w-full p-2 border rounded"
/>
<div className="max-w-3xl mx-auto bg-white rounded-lg shadow-md">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b">
<h2 className="text-sm font-medium text-gray-700">
Email from {fromEmail}
</h2>
</div>
<div className="mb-4">
<label className="block font-medium">Sujet</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full p-2 border rounded"
{/* Form */}
<div className="p-4">
{/* To */}
<RecipientInput
label="Destinataires"
recipients={recipients}
setRecipients={setRecipients}
searchRecipients={searchRecipients} // Passer l'action de recherche
establishmentId={selectedEstablishmentId} // Passer l'ID de l'établissement
/>
{/* Cc and Bcc */}
<div className="flex space-x-4">
<RecipientInput
label="Cc"
placeholder="Add Cc"
recipients={cc}
setRecipients={setCc}
/>
<RecipientInput
label="Bcc"
placeholder="Add Bcc"
recipients={bcc}
setRecipients={setBcc}
/>
</div>
{/* Subject */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700">
Subject
</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Enter subject"
className="w-full p-2 border rounded"
/>
</div>
{/* Email Body */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700">
Your email
</label>
<ReactQuill
theme="snow"
value={message}
onChange={setMessage}
placeholder="Write your email here..."
/>
</div>
{/* Footer */}
<div className="flex justify-between items-center">
<button
onClick={handleSendEmail}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
Send email
</button>
</div>
</div>
<div className="mb-4">
<label className="block font-medium">Message</label>
<Editor
apiKey="8ftyao41dcp1et0p409ipyrdtp14wxs0efqdofvrjq1vo2gi" // Remplacez par votre clé API TinyMCE
value={message}
init={{
height: 300,
menubar: false,
plugins: [
'advlist autolink lists link image charmap print preview anchor',
'searchreplace visualblocks code fullscreen',
'insertdatetime media table paste code help wordcount',
],
toolbar:
'undo redo | formatselect | bold italic backcolor | \
alignleft aligncenter alignright alignjustify | \
bullist numlist outdent indent | removeformat | help',
}}
onEditorChange={(content) => setMessage(content)}
/>
</div>
<button
onClick={handleSendEmail}
className="bg-blue-500 text-white px-4 py-2 rounded"
>
Envoyer
</button>
{status && <p className="mt-4 text-sm">{status}</p>}
</div>
);
}

View File

@ -0,0 +1,25 @@
// filepath: d:\Dev\n3wt-innov\n3wt-school\Front-End\src\components\Admin\InstantMessaging.js
import React from 'react';
import Chat from '@/components/Chat';
import { getGravatarUrl } from '@/utils/gravatar';
const contacts = [
{
id: 1,
name: 'Parent 1',
profilePic: getGravatarUrl('parent1@n3wtschool.com'),
},
{
id: 2,
name: 'Parent 2',
profilePic: getGravatarUrl('parent2@n3wtschool.com'),
},
];
export default function InstantMessaging({ csrfToken }) {
const handleSendMessage = (contact, message) => {
console.log(`Message envoyé à ${contact.name}: ${message}`);
};
return <Chat contacts={contacts} onSendMessage={handleSendMessage} />;
}

View File

@ -1,21 +1,36 @@
import React from 'react';
const AlertMessage = ({ title, message, buttonText, buttonLink }) => {
const AlertMessage = ({
type = 'info',
title,
message,
actionLabel,
onAction,
}) => {
// Définir les styles en fonction du type d'alerte
const typeStyles = {
info: 'bg-blue-100 border-blue-500 text-blue-700',
warning: 'bg-yellow-100 border-yellow-500 text-yellow-700',
error: 'bg-red-100 border-red-500 text-red-700',
success: 'bg-green-100 border-green-500 text-green-700',
};
const alertStyle = typeStyles[type] || typeStyles.info;
return (
<div
className="alert centered bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4"
role="alert"
>
<div className={`alert centered border-l-4 p-4 ${alertStyle}`} role="alert">
<h3 className="font-bold">{title}</h3>
<p className="mt-2">{message}</p>
<div className="alert-actions mt-4">
<a
className="btn primary bg-emerald-500 text-white rounded-md px-4 py-2 hover:bg-emerald-600"
href={buttonLink}
>
{buttonText} <i className="icon profile-add"></i>
</a>
</div>
{actionLabel && onAction && (
<div className="alert-actions mt-4">
<button
className="btn primary bg-emerald-500 text-white rounded-md px-4 py-2 hover:bg-emerald-600"
onClick={onAction}
>
{actionLabel}
</button>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,205 @@
import React, { useState, useRef, useEffect } from 'react';
import { SendHorizontal } from 'lucide-react';
import Image from 'next/image';
export default function Chat({
discussions,
setDiscussions,
onSendMessage,
simulateResponse,
}) {
const [selectedDiscussion, setSelectedDiscussion] = useState(null);
const [messages, setMessages] = useState({});
const [newMessage, setNewMessage] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [newDiscussionName, setNewDiscussionName] = useState('');
const [newDiscussionProfilePic, setNewDiscussionProfilePic] = useState('');
const messagesEndRef = useRef(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSendMessage = () => {
if (newMessage.trim() && selectedDiscussion) {
const discussionMessages = messages[selectedDiscussion.id] || [];
const newMessages = {
...messages,
[selectedDiscussion.id]: [
...discussionMessages,
{
id: discussionMessages.length + 1,
text: newMessage,
date: new Date(),
isResponse: false,
},
],
};
setMessages(newMessages);
setNewMessage('');
onSendMessage && onSendMessage(selectedDiscussion, newMessage);
simulateResponse && simulateResponse(selectedDiscussion.id, setMessages);
}
};
const handleKeyPress = (event) => {
if (event.key === 'Enter') {
handleSendMessage();
}
};
const handleCreateDiscussion = () => {
if (newDiscussionName.trim()) {
const newDiscussion = {
id: discussions.length + 1,
name: newDiscussionName,
profilePic: newDiscussionProfilePic || '/default-profile.png', // Image par défaut si aucune n'est fournie
lastMessage: '',
lastMessageDate: new Date(),
};
setDiscussions([...discussions, newDiscussion]);
setNewDiscussionName('');
setNewDiscussionProfilePic('');
setShowCreateForm(false);
}
};
return (
<div className="flex h-full">
{/* Liste des discussions */}
<div className="w-1/4 bg-gray-100 border-r border-gray-300 p-4 overflow-y-auto">
<h2 className="text-lg font-bold mb-4">Discussions</h2>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="w-full p-2 mb-4 bg-blue-500 text-white rounded-lg"
>
{showCreateForm ? 'Annuler' : 'Créer une discussion'}
</button>
{showCreateForm && (
<div className="mb-4 p-2 border rounded-lg bg-white">
<input
type="text"
value={newDiscussionName}
onChange={(e) => setNewDiscussionName(e.target.value)}
placeholder="Nom de la discussion"
className="w-full p-2 mb-2 border rounded"
/>
<input
type="text"
value={newDiscussionProfilePic}
onChange={(e) => setNewDiscussionProfilePic(e.target.value)}
placeholder="URL de la photo de profil (optionnel)"
className="w-full p-2 mb-2 border rounded"
/>
<button
onClick={handleCreateDiscussion}
className="w-full p-2 bg-green-500 text-white rounded-lg"
>
Ajouter
</button>
</div>
)}
{discussions && discussions.length > 0 ? (
discussions.map((discussion) => (
<div
key={discussion.id}
className={`flex items-center p-2 mb-2 cursor-pointer rounded ${
selectedDiscussion?.id === discussion.id
? 'bg-blue-100'
: 'hover:bg-gray-200'
}`}
onClick={() => setSelectedDiscussion(discussion)}
>
<Image
src={discussion.profilePic}
alt={`${discussion.name}'s profile`}
className="w-10 h-10 rounded-full mr-3"
width={40}
height={40}
/>
<div className="flex-1">
<p className="font-medium">{discussion.name}</p>
<p className="text-sm text-gray-500 truncate">
{discussion.lastMessage}
</p>
</div>
<span className="text-xs text-gray-400">
{new Date(discussion.lastMessageDate).toLocaleTimeString()}
</span>
</div>
))
) : (
<p className="text-gray-500">Aucune discussion disponible.</p>
)}
</div>
{/* Zone de chat */}
<div className="flex-1 flex flex-col bg-white">
{/* En-tête du chat */}
{selectedDiscussion && (
<div className="flex items-center p-4 border-b border-gray-300">
<Image
src={selectedDiscussion.profilePic}
alt={`${selectedDiscussion.name}'s profile`}
className="w-10 h-10 rounded-full mr-3"
width={40}
height={40}
/>
<h2 className="text-lg font-bold">{selectedDiscussion.name}</h2>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4">
{selectedDiscussion &&
(messages[selectedDiscussion.id] || []).map((message) => (
<div
key={message.id}
className={`flex mb-4 ${
message.isResponse ? 'justify-start' : 'justify-end'
}`}
>
<div
className={`p-3 rounded-lg max-w-xs ${
message.isResponse
? 'bg-gray-200 text-gray-800'
: 'bg-blue-500 text-white'
}`}
>
<p>{message.text}</p>
<span className="text-xs text-gray-500 block mt-1">
{new Date(message.date).toLocaleTimeString()}
</span>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Champ de saisie */}
{selectedDiscussion && (
<div className="p-4 border-t border-gray-300 flex items-center">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
className="flex-1 p-2 border border-gray-300 rounded-lg mr-2"
placeholder="Écrire un message..."
onKeyDown={handleKeyPress}
/>
<button
onClick={handleSendMessage}
className="p-2 bg-blue-500 text-white rounded-lg"
>
<SendHorizontal />
</button>
</div>
)}
</div>
</div>
);
}

View File

@ -9,7 +9,12 @@ const CheckBox = ({
horizontal,
}) => {
console.log(formData);
const isChecked = formData[fieldName].includes(parseInt(item.id));
// Vérifier si formData[fieldName] est un tableau ou une valeur booléenne
const isChecked = Array.isArray(formData[fieldName])
? formData[fieldName].includes(parseInt(item.id)) // Si c'est un tableau, vérifier si l'élément est inclus
: formData[fieldName]; // Si c'est une valeur booléenne, l'utiliser directement
return (
<div
key={item.id}

View File

@ -2,7 +2,7 @@ import Logo from '@/components/Logo';
export default function Footer({ softwareName, softwareVersion }) {
return (
<footer className="absolute bottom-0 left-0 right-0 h-16 bg-white border-t border-gray-200 flex items-center justify-center box-border">
<footer className="absolute bottom-0 left-64 right-0 h-16 bg-white border-t border-gray-200 flex items-center justify-center box-border">
<div className="text-sm font-light">
<span>
&copy; {new Date().getFullYear()} N3WT-INNOV Tous droits réservés.

View File

@ -0,0 +1,144 @@
import React, { useState } from 'react';
import { getGravatarUrl } from '@/utils/gravatar'; // Assurez-vous que cette fonction est définie pour générer les URLs Gravatar
import { getRightStr } from '@/utils/rights'; // Fonction existante pour récupérer le nom des rôles
export default function RecipientInput({
label,
recipients,
setRecipients,
searchRecipients, // Fonction pour effectuer la recherche
establishmentId, // ID de l'établissement
}) {
const [inputValue, setInputValue] = useState('');
const [suggestions, setSuggestions] = useState([]);
const [selectedIndex, setSelectedIndex] = useState(-1);
const handleInputChange = async (e) => {
const value = e.target.value;
setInputValue(value);
if (value.trim() !== '') {
try {
const results = await searchRecipients(establishmentId, value);
setSuggestions(results);
} catch (error) {
console.error('Erreur lors de la recherche des destinataires:', error);
setSuggestions([]);
}
} else {
setSuggestions([]);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
handleSuggestionClick(suggestions[selectedIndex]);
} else {
const trimmedValue = inputValue.trim();
if (trimmedValue && !recipients.some((r) => r.email === trimmedValue)) {
setRecipients([...recipients, { email: trimmedValue }]);
setInputValue('');
setSuggestions([]);
}
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prevIndex) =>
prevIndex < suggestions.length - 1 ? prevIndex + 1 : 0
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prevIndex) =>
prevIndex > 0 ? prevIndex - 1 : suggestions.length - 1
);
}
};
const handleSuggestionClick = (suggestion) => {
if (!recipients.some((r) => r.email === suggestion.email)) {
setRecipients([...recipients, suggestion]);
}
setInputValue('');
setSuggestions([]);
};
const handleRemoveRecipient = (email) => {
setRecipients(recipients.filter((recipient) => recipient.email !== email));
};
return (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700">{label}</label>
<div className="flex flex-wrap items-center gap-2 p-2 border rounded">
{recipients.map((recipient, index) => (
<div
key={index}
className="flex items-center bg-gray-100 text-gray-700 px-2 py-1 rounded-full"
>
<img
src={getGravatarUrl(recipient.email)}
alt={recipient.email}
className="w-6 h-6 rounded-full mr-2"
/>
<span className="mr-2">
{recipient.first_name && recipient.last_name
? `${recipient.first_name} ${recipient.last_name}`
: recipient.email}
</span>
<button
type="button"
onClick={() => handleRemoveRecipient(recipient.email)}
className="text-gray-500 hover:text-gray-700"
>
&times;
</button>
</div>
))}
<input
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Rechercher des destinataires"
className="flex-1 p-1 outline-none"
/>
</div>
{suggestions.length > 0 && (
<ul className="border rounded mt-2 bg-white shadow">
{suggestions.map((suggestion, index) => (
<li
key={suggestion.id}
className={`p-2 cursor-pointer ${
index === selectedIndex ? 'bg-gray-200' : ''
}`}
onClick={() => handleSuggestionClick(suggestion)}
>
<div className="flex items-center gap-2">
<img
src={getGravatarUrl(suggestion.email)}
alt={suggestion.email}
className="w-8 h-8 rounded-full"
/>
<div>
<p className="font-medium">
{suggestion.first_name && suggestion.last_name
? `${suggestion.first_name} ${suggestion.last_name}`
: suggestion.email}
</p>
<p className="text-sm text-gray-500">{suggestion.email}</p>
<p className="text-xs text-gray-400">
{suggestion.roles
.map((role) => getRightStr(role.role_type) || 'Inconnu')
.join(', ')}
</p>
</div>
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -12,7 +12,7 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
};
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full w-full">
{/* Tabs Header */}
<div className="flex h-14 bg-gray-50 border-b border-gray-200 shadow-sm">
{tabs.map((tab) => (
@ -31,7 +31,7 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
</div>
{/* Tabs Content */}
<div className="flex-1 overflow-y-auto p-4 rounded-b-lg shadow-inner relative">
<div className="flex-1 overflow-y-auto rounded-b-lg shadow-inner relative">
<AnimatePresence mode="wait">
{tabs.map(
(tab) =>