- Programme de parrainage (referrals): * Page /parrainage pour clients et staff * API /api/referrals (GET, POST) * Table referrals avec tracking complet * Email template avec design orange/gradient * Réductions: 30€ HT parrain, 20€ HT filleul - Bannières promotionnelles (promo_banners): * Page staff /staff/offres-promo pour gérer les bannières * API /api/promo-banners (CRUD complet) * Composant PromoBanner affiché en haut de l'espace * Compte à rebours optionnel * Customisation couleurs (gradient, texte, CTA) - Déduplication des webhooks DocuSeal: * Table webhook_events pour tracker les webhooks traités * Helper checkAndMarkWebhookProcessed() * Intégré dans docuseal-amendment et docuseal-amendment-completed * Prévient les doublons d'emails - Avenants signés: * API GET /api/contrats/[id]/avenants * Affichage des avenants signés dans DocumentsCard * Génération d'URLs presignées S3 - Brouillons d'emails groupés: * Table bulk_email_drafts pour sauvegarder les brouillons * Template HTML bulk-email-template.html - Améliorations ContractsGrid: * Ajout filtre par production (dépendant de la structure) * Tri par production - Templates emails: * referral-template.html (parrainage) * bulk-email-template.html (emails groupés staff)
1099 lines
No EOL
41 KiB
TypeScript
1099 lines
No EOL
41 KiB
TypeScript
"use client";
|
|
|
|
// components/staff/BulkEmailForm.tsx
|
|
import { useState, useRef, useEffect } from "react";
|
|
import { Eye, Send, Users, Mail, X, Check, Filter, Search, Save, FolderOpen, Bold, Italic, List } from "lucide-react";
|
|
import BulkEmailProgressModal from "./BulkEmailProgressModal";
|
|
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
|
|
|
|
// Composant d'éditeur de texte avec formatage
|
|
interface RichTextEditorProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
disabled?: boolean;
|
|
rows?: number;
|
|
placeholder?: string;
|
|
}
|
|
|
|
function RichTextEditor({ value, onChange, disabled, rows = 4, placeholder }: RichTextEditorProps) {
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const insertFormatting = (before: string, after: string = '') => {
|
|
if (!textareaRef.current) return;
|
|
|
|
const textarea = textareaRef.current;
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
const selectedText = value.substring(start, end);
|
|
const newText = value.substring(0, start) + before + selectedText + after + value.substring(end);
|
|
|
|
onChange(newText);
|
|
|
|
// Repositionner le curseur
|
|
setTimeout(() => {
|
|
textarea.focus();
|
|
const newCursorPos = start + before.length + selectedText.length;
|
|
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
|
}, 0);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex gap-1 p-2 bg-gray-50 border border-gray-200 rounded-t-lg">
|
|
<button
|
|
type="button"
|
|
onClick={() => insertFormatting('<strong>', '</strong>')}
|
|
disabled={disabled}
|
|
className="p-2 hover:bg-gray-200 rounded transition-colors disabled:opacity-50"
|
|
title="Gras"
|
|
>
|
|
<Bold className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => insertFormatting('<em>', '</em>')}
|
|
disabled={disabled}
|
|
className="p-2 hover:bg-gray-200 rounded transition-colors disabled:opacity-50"
|
|
title="Italique"
|
|
>
|
|
<Italic className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => insertFormatting('\n- ')}
|
|
disabled={disabled}
|
|
className="p-2 hover:bg-gray-200 rounded transition-colors disabled:opacity-50"
|
|
title="Puce"
|
|
>
|
|
<List className="w-4 h-4" />
|
|
</button>
|
|
<div className="flex-1" />
|
|
<span className="text-xs text-gray-500 self-center">
|
|
Sélectionnez du texte et cliquez sur un bouton pour formater
|
|
</span>
|
|
</div>
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
rows={rows}
|
|
placeholder={placeholder}
|
|
className="w-full px-3 py-2 border border-t-0 rounded-b-lg font-sans"
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface User {
|
|
id: string;
|
|
email: string;
|
|
emailConfirmed: boolean;
|
|
createdAt: string;
|
|
lastSignIn: string | null;
|
|
organizations: Array<{
|
|
id: string;
|
|
name: string;
|
|
role: string;
|
|
structure_api: string | null;
|
|
}>;
|
|
}
|
|
|
|
interface BulkEmailFormProps {
|
|
users: User[];
|
|
}
|
|
|
|
type EmailProgress = {
|
|
id: string;
|
|
email: string;
|
|
organizationName?: string;
|
|
status: 'pending' | 'sending' | 'success' | 'error';
|
|
error?: string;
|
|
sentAt?: string;
|
|
};
|
|
|
|
export default function BulkEmailForm({ users }: BulkEmailFormProps) {
|
|
const [subject, setSubject] = useState("Information importante - Espace Paie");
|
|
|
|
// États pour l'éditeur visuel
|
|
const [emailTitle, setEmailTitle] = useState("Communication importante");
|
|
const [emailGreeting, setEmailGreeting] = useState("Bonjour,");
|
|
const [emailIntro, setEmailIntro] = useState("Nous vous contactons aujourd'hui pour vous informer d'une information importante concernant votre espace paie.");
|
|
const [contentTitle, setContentTitle] = useState("Détails de la communication");
|
|
const [contentBody, setContentBody] = useState("Voici les informations importantes que nous souhaitons partager avec vous :\n\n- Point important numéro 1\n- Point important numéro 2\n- Point important numéro 3\n\nN'hésitez pas à nous contacter si vous avez des questions concernant cette communication.");
|
|
const [showAlert, setShowAlert] = useState(false);
|
|
const [alertTitle, setAlertTitle] = useState("Information importante");
|
|
const [alertText, setAlertText] = useState("Ajoutez ici un message important qui nécessite l'attention particulière des destinataires.");
|
|
const [showButton, setShowButton] = useState(false);
|
|
const [buttonText, setButtonText] = useState("Accéder à mon espace");
|
|
const [buttonUrl, setButtonUrl] = useState("https://paie.odentas.fr");
|
|
const [closingMessage, setClosingMessage] = useState("Pour toute question, n'hésitez pas à nous contacter à paie@odentas.fr.\n\nCordialement,\nL'équipe Odentas");
|
|
|
|
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
|
|
const [showPreview, setShowPreview] = useState(false);
|
|
const [editMode, setEditMode] = useState<'visual' | 'code'>('visual');
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [roleFilter, setRoleFilter] = useState("all");
|
|
const [orgFilter, setOrgFilter] = useState("all");
|
|
const [showUsersPanel, setShowUsersPanel] = useState(true);
|
|
|
|
// États pour le modal de progression
|
|
const [showProgressModal, setShowProgressModal] = useState(false);
|
|
const [emailProgress, setEmailProgress] = useState<EmailProgress[]>([]);
|
|
const [progressStats, setProgressStats] = useState({
|
|
total: 0,
|
|
completed: 0,
|
|
success: 0,
|
|
errors: 0
|
|
});
|
|
const [currentStream, setCurrentStream] = useState<AbortController | null>(null);
|
|
|
|
// États pour la gestion des brouillons
|
|
const [drafts, setDrafts] = useState<any[]>([]);
|
|
const [showDraftsModal, setShowDraftsModal] = useState(false);
|
|
const [showSaveDraftModal, setShowSaveDraftModal] = useState(false);
|
|
const [draftName, setDraftName] = useState("");
|
|
const [currentDraftId, setCurrentDraftId] = useState<string | null>(null);
|
|
const [isSavingDraft, setIsSavingDraft] = useState(false);
|
|
|
|
const previewRef = useRef<HTMLIFrameElement>(null);
|
|
const supabase = createClientComponentClient();
|
|
|
|
// Charger les brouillons au montage du composant
|
|
useEffect(() => {
|
|
loadDrafts();
|
|
}, []);
|
|
|
|
const loadDrafts = async () => {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('bulk_email_drafts')
|
|
.select('*')
|
|
.order('updated_at', { ascending: false });
|
|
|
|
if (error) throw error;
|
|
setDrafts(data || []);
|
|
} catch (error) {
|
|
console.error('Erreur lors du chargement des brouillons:', error);
|
|
}
|
|
};
|
|
|
|
const saveDraft = async () => {
|
|
if (!draftName.trim()) {
|
|
alert("Veuillez saisir un nom pour le brouillon");
|
|
return;
|
|
}
|
|
|
|
setIsSavingDraft(true);
|
|
try {
|
|
const draftData = {
|
|
draft_name: draftName,
|
|
subject,
|
|
email_title: emailTitle,
|
|
email_greeting: emailGreeting,
|
|
email_intro: emailIntro,
|
|
content_title: contentTitle,
|
|
content_body: contentBody,
|
|
show_alert: showAlert,
|
|
alert_title: alertTitle,
|
|
alert_text: alertText,
|
|
show_button: showButton,
|
|
button_text: buttonText,
|
|
button_url: buttonUrl,
|
|
closing_message: closingMessage,
|
|
filters: {
|
|
roleFilter,
|
|
orgFilter,
|
|
searchTerm
|
|
}
|
|
};
|
|
|
|
if (currentDraftId) {
|
|
// Mise à jour d'un brouillon existant
|
|
const { error } = await supabase
|
|
.from('bulk_email_drafts')
|
|
.update(draftData)
|
|
.eq('id', currentDraftId);
|
|
|
|
if (error) throw error;
|
|
alert("Brouillon mis à jour avec succès");
|
|
} else {
|
|
// Création d'un nouveau brouillon
|
|
const { data: { user } } = await supabase.auth.getUser();
|
|
if (!user) throw new Error("Utilisateur non connecté");
|
|
|
|
const { data, error } = await supabase
|
|
.from('bulk_email_drafts')
|
|
.insert([{ ...draftData, user_id: user.id }])
|
|
.select()
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
setCurrentDraftId(data.id);
|
|
alert("Brouillon enregistré avec succès");
|
|
}
|
|
|
|
await loadDrafts();
|
|
setShowSaveDraftModal(false);
|
|
setDraftName("");
|
|
} catch (error: any) {
|
|
console.error('Erreur lors de la sauvegarde du brouillon:', error);
|
|
alert(`Erreur: ${error.message}`);
|
|
} finally {
|
|
setIsSavingDraft(false);
|
|
}
|
|
};
|
|
|
|
const loadDraft = (draft: any) => {
|
|
setSubject(draft.subject);
|
|
setEmailTitle(draft.email_title);
|
|
setEmailGreeting(draft.email_greeting);
|
|
setEmailIntro(draft.email_intro);
|
|
setContentTitle(draft.content_title);
|
|
setContentBody(draft.content_body);
|
|
setShowAlert(draft.show_alert);
|
|
setAlertTitle(draft.alert_title || "");
|
|
setAlertText(draft.alert_text || "");
|
|
setShowButton(draft.show_button);
|
|
setButtonText(draft.button_text || "");
|
|
setButtonUrl(draft.button_url || "");
|
|
setClosingMessage(draft.closing_message);
|
|
|
|
// Charger les filtres si disponibles
|
|
if (draft.filters) {
|
|
setRoleFilter(draft.filters.roleFilter || "all");
|
|
setOrgFilter(draft.filters.orgFilter || "all");
|
|
setSearchTerm(draft.filters.searchTerm || "");
|
|
}
|
|
|
|
setCurrentDraftId(draft.id);
|
|
setDraftName(draft.draft_name);
|
|
setShowDraftsModal(false);
|
|
};
|
|
|
|
const deleteDraft = async (draftId: string) => {
|
|
if (!confirm("Êtes-vous sûr de vouloir supprimer ce brouillon ?")) return;
|
|
|
|
try {
|
|
const { error } = await supabase
|
|
.from('bulk_email_drafts')
|
|
.delete()
|
|
.eq('id', draftId);
|
|
|
|
if (error) throw error;
|
|
|
|
if (currentDraftId === draftId) {
|
|
setCurrentDraftId(null);
|
|
setDraftName("");
|
|
}
|
|
|
|
await loadDrafts();
|
|
alert("Brouillon supprimé avec succès");
|
|
} catch (error: any) {
|
|
console.error('Erreur lors de la suppression du brouillon:', error);
|
|
alert(`Erreur: ${error.message}`);
|
|
}
|
|
};
|
|
|
|
// Fonction pour générer le HTML à partir des champs du formulaire
|
|
const generateHtmlContent = () => {
|
|
// Fonction helper pour convertir le texte avec formatage en HTML
|
|
const processText = (text: string) => {
|
|
return text
|
|
.replace(/\n/g, '<br>'); // Conserver les sauts de ligne simples
|
|
};
|
|
|
|
// Convertir les sauts de ligne en paragraphes HTML pour le contenu principal
|
|
const contentParagraphs = contentBody
|
|
.split('\n\n')
|
|
.map(para => {
|
|
if (para.trim().startsWith('-')) {
|
|
// Convertir les listes en <ul><li>
|
|
const items = para.split('\n').filter(line => line.trim().startsWith('-'));
|
|
const listItems = items.map(item => {
|
|
const itemText = item.trim().substring(1).trim();
|
|
return `<li style="margin-bottom:8px;">${itemText}</li>`;
|
|
}).join('\n ');
|
|
return `<ul style="margin:0 0 12px 0; padding-left:20px;">\n ${listItems}\n </ul>`;
|
|
} else {
|
|
return `<p style="margin:0 0 12px 0;">\n ${para.trim()}\n </p>`;
|
|
}
|
|
})
|
|
.join('\n ');
|
|
|
|
// Convertir les sauts de ligne en <br> pour le message de clôture
|
|
const formattedClosingMessage = closingMessage.replace(/\n/g, '<br>');
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Communication Odentas</title>
|
|
<meta name="color-scheme" content="light dark">
|
|
<meta name="supported-color-schemes" content="light dark">
|
|
<style>
|
|
body { margin:0; padding:0; background:#F4F6FA; color:#0F172A; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; }
|
|
a { color:#0B5FFF; text-decoration:none; }
|
|
.wrapper { width:100%; padding:32px 0; background:#F4F6FA; }
|
|
.container { width:100%; max-width:520px; margin:0 auto; background:#FFFFFF; border-radius:14px; box-shadow: 0 6px 20px rgba(15,23,42,0.06); overflow:hidden; }
|
|
.logo { height:60px; display:block; }
|
|
.label { font-size:12px; color:#64748B; text-transform:uppercase; letter-spacing:0.03em; margin-bottom:6px; font-weight:500; }
|
|
.value { font-size:15px; color:#0F172A; font-weight:500; line-height:1.3; }
|
|
.muted { color:#94A3B8; }
|
|
.card { margin:16px 28px 0 28px; border:1px solid #E5E7EB; border-radius:12px; padding:20px; background:#F9FAFB; }
|
|
.highlight { margin:16px 28px 0 28px; border:1px solid #fde68a; border-radius:12px; padding:16px; background:#fffbeb; }
|
|
@media (prefers-color-scheme: dark) {
|
|
body, .wrapper { background:#0B1220; color:#E5E7EB; }
|
|
}
|
|
@media (max-width: 480px) {
|
|
.wrapper { padding:16px 0; }
|
|
.card, .highlight { margin:16px 16px 0 16px; padding:16px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">Communication importante de l'équipe Odentas</div>
|
|
|
|
<div class="wrapper" style="width:100%; padding:32px 0; background:#F4F6FA;">
|
|
<div class="container" style="width:100%; max-width:520px; margin:0 auto; background:#FFFFFF; border-radius:14px; box-shadow: 0 6px 20px rgba(15,23,42,0.06); overflow:hidden;">
|
|
|
|
<div class="header" style="padding:28px 28px 10px 28px; text-align:left; background:#FFFFFF;">
|
|
<img class="logo" src="https://newstaging.odentas.fr/wp-content/uploads/2025/08/Odentas-Logo-Bleu-Fond-Transparent-4-1.png" alt="Odentas" style="height:60px; display:block;">
|
|
|
|
<div style="margin:18px 0 16px 0;">
|
|
<h1 class="title" style="font-size:22px; line-height:1.3; margin:0; font-weight:700; color:#0F172A;">
|
|
${emailTitle}
|
|
</h1>
|
|
</div>
|
|
|
|
<div style="margin:20px 0;">
|
|
<p style="font-size:15px; color:#334155; margin:0 0 8px; font-weight:500;">
|
|
${emailGreeting}
|
|
</p>
|
|
<p class="subtitle" style="font-size:15px; color:#334155; margin:0; line-height:1.4;">
|
|
${emailIntro}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" style="margin:16px 28px 0 28px; border:1px solid #E5E7EB; border-radius:12px; padding:20px; background:#F9FAFB;">
|
|
<h2 style="font-size:16px; margin:0 0 12px; color:#374151; font-weight:600;">
|
|
${contentTitle}
|
|
</h2>
|
|
|
|
<div style="font-size:15px; color:#334155; line-height:1.6;">
|
|
${contentParagraphs}
|
|
</div>
|
|
</div>
|
|
|
|
${showAlert ? `<div class="highlight" style="margin:16px 28px 0 28px; border:1px solid #fde68a; border-radius:12px; padding:16px; background:#fffbeb;">
|
|
<div style="font-size:15px; font-weight:600; margin-bottom:8px; color:#854d0e;">
|
|
${alertTitle}
|
|
</div>
|
|
<div style="font-size:14px; color:#a16207;">
|
|
${alertText}
|
|
</div>
|
|
</div>` : ''}
|
|
|
|
${showButton ? `<div class="cta-wrap" style="padding:24px 28px 6px 28px; text-align:center; background:#FFFFFF;">
|
|
<a class="btn" href="${buttonUrl}" style="background:#efc543; color:#000000; display:inline-block; padding:16px 32px; border-radius:10px; font-weight:700; font-size:16px; text-decoration:none; border:none; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
|
${buttonText}
|
|
</a>
|
|
</div>` : ''}
|
|
|
|
<p class="note" style="font-size:13px; color:#475569; padding:0 28px 26px 28px; line-height:1.5;">
|
|
${formattedClosingMessage}
|
|
</p>
|
|
|
|
<div class="footer" style="font-size:12px; color:#64748B; text-align:center; padding:18px 28px 28px 28px; line-height:1.4;">
|
|
<p>
|
|
Vous recevez cet e-mail en tant qu'utilisateur de l'Espace Paie Odentas.<br>
|
|
<span class="muted" style="color:#94A3B8;">© 2021-2025 Odentas Media SAS | RCS Paris 907880348 | 6 rue d'Armaillé, 75017 Paris</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display:none; font-size:0; line-height:0;">Communication Odentas - Espace Paie</div>
|
|
</body>
|
|
</html>`;
|
|
};
|
|
|
|
// Générer le HTML content
|
|
const htmlContent = editMode === 'visual' ? generateHtmlContent() : generateHtmlContent();
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
// Filtrer les utilisateurs
|
|
const filteredUsers = users.filter(user => {
|
|
// Validation basique de l'email
|
|
const isValidEmail = user.email &&
|
|
user.email.includes('@') &&
|
|
user.email.includes('.') &&
|
|
user.email.length > 5 &&
|
|
!user.email.includes(' ');
|
|
|
|
const matchesSearch = user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
user.organizations.some(org => org.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
|
|
const matchesRole = roleFilter === "all" || user.organizations.some(org => org.role === roleFilter);
|
|
|
|
const matchesOrg = orgFilter === "all" || user.organizations.some(org => org.id === orgFilter);
|
|
|
|
return isValidEmail && matchesSearch && matchesRole && matchesOrg && user.emailConfirmed;
|
|
});
|
|
|
|
// Obtenir les organisations uniques
|
|
const allOrganizations = Array.from(new Set(
|
|
users.flatMap(user => user.organizations.map(org => ({ id: org.id, name: org.name })))
|
|
)).filter((org, index, self) =>
|
|
index === self.findIndex(o => o.id === org.id)
|
|
);
|
|
|
|
const handleSelectAll = () => {
|
|
if (selectedUsers.size === filteredUsers.length) {
|
|
setSelectedUsers(new Set());
|
|
} else {
|
|
setSelectedUsers(new Set(filteredUsers.map(user => user.id)));
|
|
}
|
|
};
|
|
|
|
const toggleUserSelection = (userId: string) => {
|
|
const newSelected = new Set(selectedUsers);
|
|
if (newSelected.has(userId)) {
|
|
newSelected.delete(userId);
|
|
} else {
|
|
newSelected.add(userId);
|
|
}
|
|
setSelectedUsers(newSelected);
|
|
};
|
|
|
|
const handleSendEmails = async () => {
|
|
if (!subject.trim() || !htmlContent.trim() || selectedUsers.size === 0) {
|
|
alert("Veuillez remplir tous les champs et sélectionner au moins un destinataire.");
|
|
return;
|
|
}
|
|
|
|
const selectedUsersList = users.filter(user => selectedUsers.has(user.id));
|
|
|
|
// Initialiser le modal de progression
|
|
const initialProgress: EmailProgress[] = selectedUsersList.map(user => ({
|
|
id: user.id,
|
|
email: user.email,
|
|
organizationName: user.organizations[0]?.name || 'Organisation inconnue',
|
|
status: 'pending'
|
|
}));
|
|
|
|
setEmailProgress(initialProgress);
|
|
setProgressStats({
|
|
total: selectedUsersList.length,
|
|
completed: 0,
|
|
success: 0,
|
|
errors: 0
|
|
});
|
|
setShowProgressModal(true);
|
|
setIsLoading(true);
|
|
|
|
// Créer AbortController pour pouvoir annuler
|
|
const abortController = new AbortController();
|
|
setCurrentStream(abortController);
|
|
|
|
try {
|
|
const response = await fetch("/api/staff/bulk-email-stream", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
subject,
|
|
htmlContent,
|
|
recipients: selectedUsersList.map(user => ({
|
|
id: user.id,
|
|
email: user.email,
|
|
organizations: user.organizations
|
|
}))
|
|
}),
|
|
signal: abortController.signal
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Erreur HTTP: ${response.status}`);
|
|
}
|
|
|
|
const reader = response.body?.getReader();
|
|
const decoder = new TextDecoder();
|
|
|
|
if (!reader) {
|
|
throw new Error("Impossible de lire la réponse streaming");
|
|
}
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
|
|
if (done) break;
|
|
|
|
const chunk = decoder.decode(value, { stream: true });
|
|
const lines = chunk.split('\n');
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
try {
|
|
const data = JSON.parse(line.slice(6));
|
|
|
|
switch (data.type) {
|
|
case 'init':
|
|
// Initialisation déjà faite
|
|
break;
|
|
|
|
case 'progress':
|
|
// Mettre à jour le statut d'un email spécifique
|
|
setEmailProgress(prev =>
|
|
prev.map(email =>
|
|
email.id === data.recipientId
|
|
? {
|
|
...email,
|
|
status: data.status,
|
|
error: data.error,
|
|
sentAt: data.sentAt
|
|
}
|
|
: email
|
|
)
|
|
);
|
|
break;
|
|
|
|
case 'batch_complete':
|
|
// Mettre à jour les statistiques globales
|
|
setProgressStats({
|
|
total: data.total,
|
|
completed: data.completed,
|
|
success: data.success,
|
|
errors: data.errors
|
|
});
|
|
break;
|
|
|
|
case 'complete':
|
|
// Envoi terminé
|
|
setProgressStats({
|
|
total: data.total,
|
|
completed: data.total,
|
|
success: data.success,
|
|
errors: data.errors
|
|
});
|
|
setIsLoading(false);
|
|
setCurrentStream(null);
|
|
break;
|
|
|
|
case 'error':
|
|
throw new Error(data.error);
|
|
}
|
|
} catch (parseError) {
|
|
console.error("Erreur parsing SSE:", parseError);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
} catch (error: any) {
|
|
console.error("Erreur:", error);
|
|
if (error.name !== 'AbortError') {
|
|
alert(`Erreur lors de l'envoi des emails: ${error.message}`);
|
|
}
|
|
setIsLoading(false);
|
|
setCurrentStream(null);
|
|
}
|
|
};
|
|
|
|
const handleCancelSending = () => {
|
|
if (currentStream) {
|
|
currentStream.abort();
|
|
setCurrentStream(null);
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCloseProgressModal = () => {
|
|
setShowProgressModal(false);
|
|
// Réinitialiser la sélection après un envoi réussi
|
|
if (!isLoading && progressStats.completed === progressStats.total) {
|
|
setSelectedUsers(new Set());
|
|
setSubject("");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex gap-6 h-[calc(100vh-200px)]">
|
|
{/* Panneau de sélection des utilisateurs */}
|
|
<div className={`${showUsersPanel ? 'w-1/3' : 'w-12'} transition-all duration-300 bg-white rounded-lg border shadow-sm flex flex-col`}>
|
|
<div className="p-4 border-b flex items-center justify-between">
|
|
{showUsersPanel ? (
|
|
<>
|
|
<div className="flex items-center gap-2">
|
|
<Users className="w-5 h-5 text-blue-600" />
|
|
<h3 className="font-semibold">Destinataires</h3>
|
|
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
|
{selectedUsers.size} sélectionné(s)
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowUsersPanel(false)}
|
|
className="text-gray-400 hover:text-gray-600 p-1"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
onClick={() => setShowUsersPanel(true)}
|
|
className="w-full flex items-center justify-center text-gray-400 hover:text-gray-600 p-2"
|
|
>
|
|
<Users className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{showUsersPanel && (
|
|
<>
|
|
{/* Filtres */}
|
|
<div className="p-4 border-b space-y-3">
|
|
<div className="relative">
|
|
<Search className="w-4 h-4 absolute left-3 top-3 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Rechercher par email ou organisation..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2 border rounded-lg text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-2">
|
|
<select
|
|
value={roleFilter}
|
|
onChange={(e) => setRoleFilter(e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg text-sm"
|
|
>
|
|
<option value="all">Tous les rôles</option>
|
|
<option value="admin">Admin</option>
|
|
<option value="user">Utilisateur</option>
|
|
<option value="viewer">Viewer</option>
|
|
</select>
|
|
|
|
<select
|
|
value={orgFilter}
|
|
onChange={(e) => setOrgFilter(e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg text-sm truncate"
|
|
title={orgFilter !== "all" ? allOrganizations.find(org => org.id === orgFilter)?.name : "Toutes les organisations"}
|
|
>
|
|
<option value="all">Toutes les orgs</option>
|
|
{allOrganizations.map(org => (
|
|
<option key={org.id} value={org.id} title={org.name}>
|
|
{org.name.length > 30 ? `${org.name.substring(0, 30)}...` : org.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleSelectAll}
|
|
className="w-full px-3 py-2 text-sm bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors"
|
|
>
|
|
{selectedUsers.size === filteredUsers.length ? "Désélectionner tout" : "Sélectionner tout"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Liste des utilisateurs */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{filteredUsers.map(user => (
|
|
<div
|
|
key={user.id}
|
|
className={`p-3 border-b cursor-pointer hover:bg-gray-50 ${
|
|
selectedUsers.has(user.id) ? 'bg-blue-50 border-blue-200' : ''
|
|
}`}
|
|
onClick={() => toggleUserSelection(user.id)}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center ${
|
|
selectedUsers.has(user.id)
|
|
? 'bg-blue-600 border-blue-600'
|
|
: 'border-gray-300'
|
|
}`}>
|
|
{selectedUsers.has(user.id) && (
|
|
<Check className="w-3 h-3 text-white" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium text-sm truncate">{user.email}</div>
|
|
<div className="text-xs text-gray-500">
|
|
{user.organizations.map(org => org.name).join(", ")}
|
|
</div>
|
|
<div className="text-xs text-gray-400">
|
|
{user.organizations.map(org => org.role).join(", ")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{filteredUsers.length === 0 && (
|
|
<div className="p-6 text-center text-gray-500">
|
|
Aucun utilisateur trouvé avec ces filtres
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Formulaire de composition */}
|
|
<div className="flex-1 bg-white rounded-lg border shadow-sm flex flex-col">
|
|
<div className="p-4 border-b">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Mail className="w-5 h-5 text-green-600" />
|
|
<h3 className="font-semibold">Composition de l'email</h3>
|
|
{currentDraftId && draftName && (
|
|
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded">
|
|
{draftName}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setShowDraftsModal(true)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
|
|
title="Charger un brouillon"
|
|
>
|
|
<FolderOpen className="w-4 h-4" />
|
|
Brouillons
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setShowSaveDraftModal(true)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-100 text-indigo-700 rounded-lg hover:bg-indigo-200 transition-colors"
|
|
title="Enregistrer comme brouillon"
|
|
>
|
|
<Save className="w-4 h-4" />
|
|
Enregistrer
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setShowPreview(!showPreview)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
{showPreview ? "Éditer" : "Aperçu"}
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleSendEmails}
|
|
disabled={isLoading || selectedUsers.size === 0}
|
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
{isLoading ? "Envoi..." : `Envoyer (${selectedUsers.size})`}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sujet */}
|
|
<div className="space-y-3">
|
|
<input
|
|
type="text"
|
|
placeholder="Sujet de l'email"
|
|
value={subject}
|
|
onChange={(e) => setSubject(e.target.value)}
|
|
className="w-full px-4 py-3 border rounded-lg text-lg"
|
|
disabled={isLoading}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Contenu */}
|
|
<div className="flex-1 flex overflow-auto">
|
|
{!showPreview ? (
|
|
<div className="w-full p-4 space-y-4 overflow-auto">
|
|
<div className="space-y-3">
|
|
<label className="block text-sm font-medium text-gray-700">
|
|
Titre principal
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={emailTitle}
|
|
onChange={(e) => setEmailTitle(e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg"
|
|
disabled={isLoading}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<label className="block text-sm font-medium text-gray-700">
|
|
Salutation
|
|
</label>
|
|
<RichTextEditor
|
|
value={emailGreeting}
|
|
onChange={setEmailGreeting}
|
|
disabled={isLoading}
|
|
rows={2}
|
|
placeholder="Bonjour,"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<label className="block text-sm font-medium text-gray-700">
|
|
Message d'introduction
|
|
</label>
|
|
<textarea
|
|
value={emailIntro}
|
|
onChange={(e) => setEmailIntro(e.target.value)}
|
|
rows={2}
|
|
className="w-full px-3 py-2 border rounded-lg"
|
|
disabled={isLoading}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<label className="block text-sm font-medium text-gray-700">
|
|
Titre de la section principale
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={contentTitle}
|
|
onChange={(e) => setContentTitle(e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg"
|
|
disabled={isLoading}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<label className="block text-sm font-medium text-gray-700">
|
|
Contenu principal
|
|
<span className="text-xs text-gray-500 ml-2">(Utilisez des tirets (-) en début de ligne pour créer des listes à puces)</span>
|
|
</label>
|
|
<RichTextEditor
|
|
value={contentBody}
|
|
onChange={setContentBody}
|
|
disabled={isLoading}
|
|
rows={8}
|
|
placeholder="Votre contenu..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-3 border-t pt-4">
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={showAlert}
|
|
onChange={(e) => setShowAlert(e.target.checked)}
|
|
className="rounded"
|
|
disabled={isLoading}
|
|
/>
|
|
<span className="text-sm font-medium text-gray-700">Afficher un encadré d'alerte</span>
|
|
</label>
|
|
|
|
{showAlert && (
|
|
<div className="ml-6 space-y-3">
|
|
<input
|
|
type="text"
|
|
value={alertTitle}
|
|
onChange={(e) => setAlertTitle(e.target.value)}
|
|
placeholder="Titre de l'alerte"
|
|
className="w-full px-3 py-2 border rounded-lg"
|
|
disabled={isLoading}
|
|
/>
|
|
<RichTextEditor
|
|
value={alertText}
|
|
onChange={setAlertText}
|
|
disabled={isLoading}
|
|
rows={3}
|
|
placeholder="Texte de l'alerte"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-3 border-t pt-4">
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={showButton}
|
|
onChange={(e) => setShowButton(e.target.checked)}
|
|
className="rounded"
|
|
disabled={isLoading}
|
|
/>
|
|
<span className="text-sm font-medium text-gray-700">Afficher un bouton d'action</span>
|
|
</label>
|
|
|
|
{showButton && (
|
|
<div className="ml-6 space-y-3">
|
|
<input
|
|
type="text"
|
|
value={buttonText}
|
|
onChange={(e) => setButtonText(e.target.value)}
|
|
placeholder="Texte du bouton"
|
|
className="w-full px-3 py-2 border rounded-lg"
|
|
disabled={isLoading}
|
|
/>
|
|
<input
|
|
type="url"
|
|
value={buttonUrl}
|
|
onChange={(e) => setButtonUrl(e.target.value)}
|
|
placeholder="URL du bouton"
|
|
className="w-full px-3 py-2 border rounded-lg"
|
|
disabled={isLoading}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-3 border-t pt-4">
|
|
<label className="block text-sm font-medium text-gray-700">
|
|
Message de clôture
|
|
</label>
|
|
<textarea
|
|
value={closingMessage}
|
|
onChange={(e) => setClosingMessage(e.target.value)}
|
|
rows={4}
|
|
className="w-full px-3 py-2 border rounded-lg"
|
|
disabled={isLoading}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="w-full p-4 overflow-auto bg-gray-50">
|
|
<div className="text-center mb-2">
|
|
<span className="text-xs text-gray-500">Aperçu de l'email</span>
|
|
</div>
|
|
<div className="max-w-2xl mx-auto">
|
|
<iframe
|
|
ref={previewRef}
|
|
srcDoc={htmlContent}
|
|
className="w-full border-0 rounded-lg shadow-sm bg-white"
|
|
style={{
|
|
minHeight: '600px',
|
|
height: 'calc(100vh - 300px)'
|
|
}}
|
|
title="Aperçu de l'email"
|
|
sandbox="allow-same-origin"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modal de progression */}
|
|
<BulkEmailProgressModal
|
|
isOpen={showProgressModal}
|
|
onClose={handleCloseProgressModal}
|
|
emails={emailProgress}
|
|
onCancel={isLoading ? handleCancelSending : undefined}
|
|
isProcessing={isLoading}
|
|
totalCount={progressStats.total}
|
|
completedCount={progressStats.completed}
|
|
successCount={progressStats.success}
|
|
errorCount={progressStats.errors}
|
|
subject={subject}
|
|
/>
|
|
|
|
{/* Modal de sauvegarde de brouillon */}
|
|
{showSaveDraftModal && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
|
<h3 className="text-lg font-semibold mb-4">Enregistrer le brouillon</h3>
|
|
<input
|
|
type="text"
|
|
value={draftName}
|
|
onChange={(e) => setDraftName(e.target.value)}
|
|
placeholder="Nom du brouillon..."
|
|
className="w-full px-4 py-2 border rounded-lg mb-4"
|
|
autoFocus
|
|
/>
|
|
<div className="flex gap-2 justify-end">
|
|
<button
|
|
onClick={() => {
|
|
setShowSaveDraftModal(false);
|
|
setDraftName("");
|
|
}}
|
|
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
|
disabled={isSavingDraft}
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
onClick={saveDraft}
|
|
disabled={isSavingDraft || !draftName.trim()}
|
|
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
{isSavingDraft ? "Enregistrement..." : "Enregistrer"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal de chargement de brouillon */}
|
|
{showDraftsModal && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[80vh] overflow-hidden flex flex-col">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold">Mes brouillons</h3>
|
|
<button
|
|
onClick={() => setShowDraftsModal(false)}
|
|
className="text-gray-400 hover:text-gray-600"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
{drafts.length === 0 ? (
|
|
<p className="text-center text-gray-500 py-8">Aucun brouillon enregistré</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{drafts.map((draft) => (
|
|
<div
|
|
key={draft.id}
|
|
className="border rounded-lg p-4 hover:bg-gray-50 transition-colors"
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<h4 className="font-medium text-gray-900">{draft.draft_name}</h4>
|
|
<p className="text-sm text-gray-600 mt-1">{draft.subject}</p>
|
|
<p className="text-xs text-gray-400 mt-2">
|
|
Modifié le {new Date(draft.updated_at).toLocaleDateString('fr-FR', {
|
|
day: 'numeric',
|
|
month: 'long',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2 ml-4">
|
|
<button
|
|
onClick={() => loadDraft(draft)}
|
|
className="px-3 py-1 text-sm bg-indigo-100 text-indigo-700 rounded hover:bg-indigo-200 transition-colors"
|
|
>
|
|
Charger
|
|
</button>
|
|
<button
|
|
onClick={() => deleteDraft(draft.id)}
|
|
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
|
|
>
|
|
Supprimer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |