espace-paie-odentas/components/staff/BulkEmailForm.tsx
odentas 6170365fc0 feat: Ajout programme de parrainage, bannières promo, déduplication webhooks et avenants signés
- 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)
2025-10-31 23:31:53 +01:00

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