| import { useState, useEffect } from 'react'; |
| import { useTranslation } from 'react-i18next'; |
| import { Users, Upload, Search, Download, Trash2, Filter, Loader2, FileSpreadsheet, CheckCircle2, Sparkles, BrainCircuit, Send, Copy, RefreshCw } from 'lucide-react'; |
| import { api } from '../lib/api'; |
| import { useAuth } from '../lib/auth'; |
| import { useTenant } from '../lib/tenant'; |
| import { TemplateSelector } from '../components/whatsapp/TemplateSelector'; |
| import { useToast } from '../hooks/useToast'; |
| import { logError, logWarn } from '../lib/logger'; |
|
|
| interface Contact { |
| id: string; |
| phoneNumber: string; |
| name?: string; |
| attributes?: any; |
| tags?: string[]; |
| createdAt: string; |
| } |
|
|
| export default function ContactsPage() { |
| const { t } = useTranslation(); |
| const toast = useToast(); |
| const { token } = useAuth(); |
| const { selectedOrgId } = useTenant(); |
| const [contacts, setContacts] = useState<Contact[]>([]); |
| const [loading, setLoading] = useState(true); |
| const [searchQuery, setSearchQuery] = useState(''); |
| const [showImportModal, setShowImportModal] = useState(false); |
| const [uploading, setUploading] = useState(false); |
| const [uploadingFileName, setUploadingFileName] = useState<string>(''); |
| const [importStats, setImportStats] = useState<any>(null); |
|
|
| |
| const [selectedContactForAI, setSelectedContactForAI] = useState<Contact | null>(null); |
| const [generatingAI, setGeneratingAI] = useState(false); |
| const [aiResult, setAiResult] = useState<{ personalizedMessage: string; reasoning: string; aiSource: string } | null>(null); |
| const [campaignObjective, setCampaignObjective] = useState("Proposer nos nouveaux services de formation IA"); |
| const [selectedTemplateName, setSelectedTemplateName] = useState(''); |
|
|
| |
| const [selectedContactIds, setSelectedContactIds] = useState<string[]>([]); |
| const [showBulkModal, setShowBulkModal] = useState(false); |
| const [bulkProgress, setBulkProgress] = useState({ current: 0, total: 0, status: 'idle' }); |
| const [bulkResults, setBulkResults] = useState<any[]>([]); |
| const [activeCount, setActiveCount] = useState<number | null>(null); |
| const [showFilters, setShowFilters] = useState(false); |
|
|
| |
| const [availableTags, setAvailableTags] = useState<string[]>([]); |
| const [activeTagFilter, setActiveTagFilter] = useState<string[]>([]); |
| const [tagInputs, setTagInputs] = useState<Record<string, string>>({}); |
| const [savingTags, setSavingTags] = useState<Record<string, boolean>>({}); |
|
|
| const fetchAvailableTags = async () => { |
| if (!token || !selectedOrgId) return; |
| try { |
| const data = await api.get(`/v1/organizations/${selectedOrgId}/contacts/tags`, token); |
| setAvailableTags(data.tags ?? []); |
| } catch { } |
| }; |
|
|
| const fetchContacts = async (tagFilter?: string[]) => { |
| if (!token || !selectedOrgId) return; |
| setLoading(true); |
| try { |
| const tags = tagFilter ?? activeTagFilter; |
| const qs = tags.length > 0 ? `?tags=${tags.join(',')}` : ''; |
| const data = await api.get(`/v1/organizations/${selectedOrgId}/contacts${qs}`, token); |
| setContacts(data); |
| } catch (error) { |
| logError("Failed to fetch contacts:", error); |
| } finally { |
| setLoading(false); |
| } |
| }; |
|
|
| const toggleTagFilter = (tag: string) => { |
| const newFilter = activeTagFilter.includes(tag) |
| ? activeTagFilter.filter(t => t !== tag) |
| : [...activeTagFilter, tag]; |
| setActiveTagFilter(newFilter); |
| fetchContacts(newFilter); |
| }; |
|
|
| const handleSaveContactTags = async (contactId: string, newTags: string[]) => { |
| if (!token || !selectedOrgId) return; |
| setSavingTags(prev => ({ ...prev, [contactId]: true })); |
| try { |
| await api.patch(`/v1/organizations/${selectedOrgId}/contacts/${contactId}/tags`, { tags: newTags }, token); |
| setContacts(prev => prev.map(c => c.id === contactId ? { ...c, tags: newTags } : c)); |
| fetchAvailableTags(); |
| } catch (err: any) { |
| toast.error(err?.message ?? t('contacts.tags_update_error')); |
| } finally { |
| setSavingTags(prev => ({ ...prev, [contactId]: false })); |
| } |
| }; |
|
|
| const handleAddTag = (contactId: string, currentTags: string[]) => { |
| const input = (tagInputs[contactId] ?? '').trim().toLowerCase().replace(/\s+/g, '-'); |
| if (!input || currentTags.includes(input)) return; |
| handleSaveContactTags(contactId, [...currentTags, input]); |
| setTagInputs(prev => ({ ...prev, [contactId]: '' })); |
| }; |
|
|
| const handleRemoveTag = (contactId: string, tag: string, currentTags: string[]) => { |
| handleSaveContactTags(contactId, currentTags.filter(t => t !== tag)); |
| }; |
|
|
| useEffect(() => { |
| let cancelled = false; |
|
|
| const loadAll = async () => { |
| if (!token || !selectedOrgId) return; |
| setLoading(true); |
| try { |
| const tags = activeTagFilter; |
| const qs = tags.length > 0 ? `?tags=${tags.join(',')}` : ''; |
| const [contactsData, tagsData] = await Promise.all([ |
| api.get(`/v1/organizations/${selectedOrgId}/contacts${qs}`, token), |
| api.get(`/v1/organizations/${selectedOrgId}/contacts/tags`, token).catch(() => ({ tags: [] })), |
| ]); |
| if (!cancelled) { |
| setContacts(contactsData); |
| setAvailableTags(tagsData.tags ?? []); |
| } |
| } catch (error) { |
| if (!cancelled) logError("Failed to fetch contacts:", error); |
| } finally { |
| if (!cancelled) setLoading(false); |
| } |
|
|
| try { |
| const data = await api.get('/v1/analytics/usage', token, selectedOrgId); |
| if (!cancelled) setActiveCount(data?.users?.activeLast24h ?? 0); |
| } catch (err) { |
| if (!cancelled) { logWarn('[Contacts] fetchActiveCount failed', err); setActiveCount(0); } |
| } |
| }; |
|
|
| loadAll(); |
| return () => { cancelled = true; }; |
| }, [token, selectedOrgId]); |
|
|
| const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { |
| const file = e.target.files?.[0]; |
| if (!file || !token || !selectedOrgId) return; |
|
|
| setUploading(true); |
| setUploadingFileName(file.name); |
| const formData = new FormData(); |
| formData.append('file', file); |
|
|
| try { |
| const data = await api.upload(`/v1/organizations/${selectedOrgId}/contacts/import`, formData, token, selectedOrgId); |
| const results = data.results; |
| setImportStats(results); |
| fetchContacts(); |
| if (results) { |
| toast.success(t('contacts.import_success', { created: results.created ?? 0, updated: results.updated ?? 0, errors: results.errors ?? 0 })); |
| } |
| } catch (error) { |
| logError("Import failed:", error); |
| toast.error(t('contacts.upload_critical_error')); |
| } finally { |
| setUploading(false); |
| setUploadingFileName(''); |
| e.target.value = ''; |
| } |
| }; |
|
|
| const handleGenerateAI = async (contact: Contact) => { |
| if (!token || !selectedOrgId) return; |
| setSelectedContactForAI(contact); |
| setGeneratingAI(true); |
| setAiResult(null); |
|
|
| try { |
| const data = await api.post('/v1/ai/crm/generate-campaign', { |
| contact, |
| objective: campaignObjective |
| }, token, selectedOrgId); |
| |
| setAiResult(data); |
| } catch (error) { |
| logError("AI Generation failed:", error); |
| toast.error(t('contacts.ai_generation_error')); |
| } finally { |
| setGeneratingAI(false); |
| } |
| }; |
|
|
| const handleBulkGenerate = async () => { |
| if (!token || !selectedOrgId || selectedContactIds.length === 0) return; |
| |
| setShowBulkModal(true); |
| setBulkProgress({ current: 0, total: selectedContactIds.length, status: 'processing' }); |
| setBulkResults([]); |
| |
| const results = []; |
| const selectedContacts = contacts.filter(c => selectedContactIds.includes(c.id)); |
|
|
| for (let i = 0; i < selectedContacts.length; i++) { |
| const contact = selectedContacts[i]; |
| setBulkProgress(prev => ({ ...prev, current: i + 1 })); |
| |
| try { |
| const data = await api.post('/v1/ai/crm/generate-campaign', { contact, objective: campaignObjective }, token, selectedOrgId); |
| results.push({ contact, ...data }); |
| } catch (err) { |
| results.push({ contact, error: true, personalizedMessage: t('contacts.generation_error_fallback') }); |
| } |
| } |
|
|
| setBulkResults(results); |
| setBulkProgress(prev => ({ ...prev, status: 'completed' })); |
| }; |
|
|
| const toggleSelectAll = () => { |
| if (selectedContactIds.length === filteredContacts.length) { |
| setSelectedContactIds([]); |
| } else { |
| setSelectedContactIds(filteredContacts.map(c => c.id)); |
| } |
| }; |
|
|
| const toggleSelectContact = (id: string) => { |
| setSelectedContactIds(prev => |
| prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id] |
| ); |
| }; |
|
|
| const handleDeleteContact = async (id: string) => { |
| if (!token || !selectedOrgId) return; |
| if (!confirm(t('contacts.confirm_delete_one'))) return; |
|
|
| try { |
| const res = await api.delete(`/v1/organizations/${selectedOrgId}/contacts/${id}`, token); |
| if (res.ok || res.success || res) { |
| setContacts(prev => prev.filter(c => c.id !== id)); |
| setSelectedContactIds(prev => prev.filter(i => i !== id)); |
| } |
| } catch (error) { |
| logError("Delete failed:", error); |
| toast.error(t('contacts.delete_error')); |
| } |
| }; |
|
|
| const handleBulkDelete = async () => { |
| if (!token || !selectedOrgId || selectedContactIds.length === 0) return; |
| if (!confirm(t('contacts.confirm_delete_many', { count: selectedContactIds.length }))) return; |
|
|
| try { |
| await api.post(`/v1/organizations/${selectedOrgId}/contacts/bulk-delete`, { contactIds: selectedContactIds }, token, selectedOrgId); |
| setContacts(prev => prev.filter(c => !selectedContactIds.includes(c.id))); |
| setSelectedContactIds([]); |
| toast.success(t('contacts.bulk_delete_success')); |
| } catch (error) { |
| logError("Bulk delete failed:", error); |
| toast.error(t('contacts.bulk_delete_error')); |
| } |
| }; |
|
|
| const handleExportCsv = () => { |
| if (filteredContacts.length === 0) return; |
| const headers = [t('contacts.csv_name'), t('contacts.csv_phone'), t('contacts.csv_created')]; |
| const rows = filteredContacts.map(c => [ |
| c.name ?? '', |
| c.phoneNumber, |
| new Date(c.createdAt).toLocaleDateString('fr-FR') |
| ]); |
| const csv = [headers, ...rows].map(r => r.map(v => `"${v}"`).join(',')).join('\n'); |
| const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `contacts-${new Date().toISOString().slice(0, 10)}.csv`; |
| a.click(); |
| URL.revokeObjectURL(url); |
| }; |
|
|
| const filteredContacts = contacts.filter(c => { |
| const searchLower = searchQuery.toLowerCase(); |
| const inName = c.name?.toLowerCase().includes(searchLower); |
| const inPhone = c.phoneNumber.includes(searchQuery); |
| |
| |
| const inAttributes = c.attributes && Object.values(c.attributes).some(val => |
| String(val).toLowerCase().includes(searchLower) |
| ); |
|
|
| return inName || inPhone || inAttributes; |
| }); |
|
|
| return ( |
| <div className="p-8 max-w-7xl mx-auto"> |
| {/* Header Area */} |
| <div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-10"> |
| <div> |
| <h1 className="text-4xl font-black text-slate-900 tracking-tight flex items-center gap-3"> |
| <Users className="w-10 h-10 text-blue-600" /> |
| {t('contacts.title')} |
| </h1> |
| <p className="text-slate-500 mt-2 font-medium">{t('contacts.subtitle')}</p> |
| </div> |
| |
| <div className="flex items-center gap-3"> |
| <button |
| onClick={() => setShowImportModal(true)} |
| className="flex items-center gap-2 bg-blue-600 text-white px-6 py-3.5 rounded-2xl font-bold hover:bg-blue-700 transition shadow-xl shadow-blue-100 hover:scale-[1.02] active:scale-[0.98]" |
| > |
| <Upload className="w-5 h-5" /> Import Excel/CSV |
| </button> |
| <button |
| onClick={handleExportCsv} |
| title="Exporter en CSV" |
| className="p-3.5 bg-white border border-slate-200 text-slate-600 rounded-2xl hover:bg-slate-50 transition shadow-sm" |
| > |
| <Download className="w-5 h-5" /> |
| </button> |
| </div> |
| </div> |
| |
| {/* Stats Bar */} |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> |
| <div className="bg-white p-6 rounded-[2rem] border border-slate-100 shadow-sm flex items-center gap-4"> |
| <div className="w-12 h-12 bg-blue-50 rounded-2xl flex items-center justify-center"> |
| <Users className="w-6 h-6 text-blue-600" /> |
| </div> |
| <div> |
| <p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Total Contacts</p> |
| <p className="text-2xl font-black text-slate-900">{contacts.length}</p> |
| </div> |
| </div> |
| <div className="bg-white p-6 rounded-[2rem] border border-slate-100 shadow-sm flex items-center gap-4"> |
| <div className="w-12 h-12 bg-emerald-50 rounded-2xl flex items-center justify-center"> |
| <CheckCircle2 className="w-6 h-6 text-emerald-600" /> |
| </div> |
| <div> |
| <p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Actifs (24h)</p> |
| <p className="text-2xl font-black text-slate-900">{activeCount ?? '—'}</p> |
| </div> |
| </div> |
| <div className="bg-white p-6 rounded-[2rem] border border-slate-100 shadow-sm flex items-center gap-4"> |
| <div className="w-12 h-12 bg-indigo-50 rounded-2xl flex items-center justify-center"> |
| <Filter className="w-6 h-6 text-indigo-600" /> |
| </div> |
| <div> |
| <p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Segments</p> |
| <p className="text-2xl font-black text-slate-900">{searchQuery ? 1 : contacts.length > 0 ? 1 : 0}</p> |
| </div> |
| </div> |
| </div> |
| |
| {/* Filters & Table */} |
| <div className="bg-white rounded-[2.5rem] border border-slate-100 shadow-sm overflow-hidden"> |
| <div className="p-6 border-b border-slate-50 flex flex-col gap-4"> |
| <div className="flex flex-col md:flex-row md:items-center gap-4"> |
| <div className="relative flex-1"> |
| <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" /> |
| <input |
| type="text" |
| placeholder={t('contacts.search_placeholder')} |
| value={searchQuery} |
| onChange={e => setSearchQuery(e.target.value)} |
| className="w-full bg-slate-50 border-none rounded-2xl pl-12 pr-4 py-4 focus:ring-4 focus:ring-blue-50 transition font-medium" |
| /> |
| </div> |
| <div className="flex items-center gap-2"> |
| <button |
| onClick={() => setShowFilters(v => !v)} |
| className={`flex items-center gap-2 px-5 py-4 rounded-2xl font-bold transition ${showFilters || activeTagFilter.length > 0 ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50'}`} |
| > |
| <Filter className="w-4 h-4" /> Filtres {activeTagFilter.length > 0 && <span className="bg-blue-600 text-white text-[10px] rounded-full px-1.5 py-0.5">{activeTagFilter.length}</span>} |
| </button> |
| </div> |
| </div> |
| |
| {/* Tag filter chips */} |
| {(showFilters || activeTagFilter.length > 0) && availableTags.length > 0 && ( |
| <div className="flex flex-wrap gap-2 pt-1"> |
| <span className="text-xs font-bold text-slate-400 uppercase tracking-wider self-center">Tags :</span> |
| {availableTags.map(tag => ( |
| <button |
| key={tag} |
| onClick={() => toggleTagFilter(tag)} |
| className={`px-3 py-1 rounded-full text-xs font-bold transition-all ${ |
| activeTagFilter.includes(tag) |
| ? 'bg-blue-600 text-white shadow-sm' |
| : 'bg-slate-100 text-slate-600 hover:bg-slate-200' |
| }`} |
| > |
| #{tag} |
| </button> |
| ))} |
| {activeTagFilter.length > 0 && ( |
| <button |
| onClick={() => { setActiveTagFilter([]); fetchContacts([]); }} |
| className="px-3 py-1 rounded-full text-xs font-bold text-red-500 hover:bg-red-50 transition-all" |
| > |
| × Effacer |
| </button> |
| )} |
| </div> |
| )} |
| |
| {showFilters && availableTags.length === 0 && ( |
| <p className="text-xs text-slate-400 italic">Aucun tag créé. Ajoutez des tags sur vos contacts pour les segmenter.</p> |
| )} |
| </div> |
| |
| <div className="overflow-x-auto"> |
| <table className="w-full text-left"> |
| <thead> |
| <tr className="bg-slate-50/50"> |
| <th className="px-8 py-5 w-10"> |
| <input |
| type="checkbox" |
| className="w-5 h-5 rounded border-slate-300 text-blue-600 focus:ring-blue-500" |
| checked={selectedContactIds.length > 0 && selectedContactIds.length === filteredContacts.length} |
| onChange={toggleSelectAll} |
| /> |
| </th> |
| <th className="px-8 py-5 text-xs font-bold text-slate-400 uppercase tracking-widest">Client</th> |
| <th className="px-8 py-5 text-xs font-bold text-slate-400 uppercase tracking-widest">Téléphone</th> |
| <th className="px-8 py-5 text-xs font-bold text-slate-400 uppercase tracking-widest">Tags</th> |
| <th className="px-8 py-5 text-xs font-bold text-slate-400 uppercase tracking-widest">Attributs</th> |
| <th className="px-8 py-5 text-xs font-bold text-slate-400 uppercase tracking-widest text-right">Actions</th> |
| </tr> |
| </thead> |
| <tbody className="divide-y divide-slate-50"> |
| {loading ? ( |
| <tr> |
| <td colSpan={6} className="px-8 py-20 text-center"> |
| <Loader2 className="w-10 h-10 animate-spin text-blue-600 mx-auto mb-4" /> |
| <p className="font-bold text-slate-400">Chargement de la base de données...</p> |
| </td> |
| </tr> |
| ) : filteredContacts.length === 0 ? ( |
| <tr> |
| <td colSpan={6} className="px-8 py-20 text-center"> |
| <div className="w-20 h-20 bg-slate-50 rounded-[2rem] flex items-center justify-center mx-auto mb-6"> |
| <Users className="w-10 h-10 text-slate-200" /> |
| </div> |
| <h3 className="text-xl font-bold text-slate-900 mb-2">{t('contacts.no_contacts')}</h3> |
| <p className="text-slate-500 max-w-sm mx-auto">{t('contacts.subtitle')}</p> |
| </td> |
| </tr> |
| ) : filteredContacts.map(contact => ( |
| <tr key={contact.id} className={`hover:bg-slate-50/50 transition ${selectedContactIds.includes(contact.id) ? 'bg-blue-50/30' : ''}`}> |
| <td className="px-8 py-6"> |
| <input |
| type="checkbox" |
| className="w-5 h-5 rounded border-slate-300 text-blue-600 focus:ring-blue-500" |
| checked={selectedContactIds.includes(contact.id)} |
| onChange={() => toggleSelectContact(contact.id)} |
| /> |
| </td> |
| <td className="px-8 py-6"> |
| <div className="flex items-center gap-4"> |
| <div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center font-bold text-slate-600 uppercase"> |
| {contact.name?.[0] || '?'} |
| </div> |
| <div> |
| <p className="font-bold text-slate-900">{contact.name || 'Inconnu'}</p> |
| <p className="text-xs text-slate-400">Importé le {new Date(contact.createdAt).toLocaleDateString()}</p> |
| </div> |
| </div> |
| </td> |
| <td className="px-8 py-6 font-mono text-sm font-bold text-slate-600"> |
| +{contact.phoneNumber} |
| </td> |
| {/* Tags column */} |
| <td className="px-8 py-6"> |
| <div className="flex flex-wrap gap-1.5 max-w-[200px]"> |
| {(contact.tags ?? []).map(tag => ( |
| <span |
| key={tag} |
| className="group flex items-center gap-1 px-2 py-0.5 bg-indigo-50 text-indigo-700 rounded-full text-[10px] font-bold" |
| > |
| #{tag} |
| <button |
| onClick={() => handleRemoveTag(contact.id, tag, contact.tags ?? [])} |
| className="opacity-0 group-hover:opacity-100 text-indigo-400 hover:text-red-500 transition-opacity leading-none" |
| >×</button> |
| </span> |
| ))} |
| <div className="flex items-center gap-1"> |
| <input |
| type="text" |
| value={tagInputs[contact.id] ?? ''} |
| onChange={e => setTagInputs(prev => ({ ...prev, [contact.id]: e.target.value }))} |
| onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddTag(contact.id, contact.tags ?? []); }}} |
| placeholder="+ tag" |
| className="w-16 text-[10px] px-1.5 py-0.5 border border-dashed border-slate-300 rounded-full outline-none focus:border-indigo-400 bg-transparent text-slate-500" |
| /> |
| {savingTags[contact.id] && <Loader2 className="w-3 h-3 animate-spin text-indigo-400" />} |
| </div> |
| </div> |
| </td> |
| <td className="px-8 py-6"> |
| <div className="flex flex-wrap gap-2"> |
| {contact.attributes && Object.entries(contact.attributes).slice(0, 3).map(([key, val]) => ( |
| <span key={key} className="text-[10px] px-2 py-1 bg-slate-100 text-slate-500 rounded-lg font-bold uppercase tracking-tight"> |
| {key}: {String(val)} |
| </span> |
| ))} |
| {Object.keys(contact.attributes || {}).length > 3 && ( |
| <span className="text-[10px] px-2 py-1 bg-blue-50 text-blue-600 rounded-lg font-bold"> |
| +{Object.keys(contact.attributes).length - 3} plus |
| </span> |
| )} |
| </div> |
| </td> |
| <td className="px-8 py-6 text-right"> |
| <div className="flex items-center justify-end gap-2"> |
| <button |
| onClick={() => handleGenerateAI(contact)} |
| className="flex items-center gap-2 bg-indigo-50 text-indigo-600 px-4 py-2 rounded-xl font-bold hover:bg-indigo-100 transition" |
| > |
| <Sparkles className="w-4 h-4" /> IA |
| </button> |
| <button |
| onClick={() => handleDeleteContact(contact.id)} |
| className="p-2 text-slate-400 hover:text-red-500 transition" |
| > |
| <Trash2 className="w-5 h-5" /> |
| </button> |
| </div> |
| </td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| </div> |
| |
| {/* Import Modal */} |
| {showImportModal && ( |
| <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-md flex items-center justify-center p-6 z-50"> |
| <div className="bg-white rounded-[2.5rem] shadow-2xl w-full max-w-xl p-10 animate-in zoom-in-95 duration-300"> |
| <div className="flex items-center justify-between mb-8"> |
| <h2 className="text-3xl font-black text-slate-900">Importation Excel</h2> |
| <button onClick={() => { setShowImportModal(false); setImportStats(null); }} className="p-2 hover:bg-slate-100 rounded-full transition"> |
| <Trash2 className="w-6 h-6 text-slate-300" /> |
| </button> |
| </div> |
| |
| {!importStats ? ( |
| <div className="space-y-8"> |
| <p className="text-slate-500 font-medium leading-relaxed"> |
| Veuillez uploader un fichier <span className="font-bold text-slate-900">.xlsx</span> ou <span className="font-bold text-slate-900">.csv</span>. |
| L'IA détectera automatiquement les colonnes de noms et de numéros. |
| </p> |
| |
| <label className="relative block group cursor-pointer"> |
| <div className="w-full border-4 border-dashed border-slate-100 rounded-[2rem] p-12 flex flex-col items-center justify-center group-hover:border-blue-200 transition bg-slate-50/50"> |
| {uploading ? ( |
| <div className="flex flex-col items-center gap-3"> |
| <Loader2 className="w-12 h-12 animate-spin text-blue-600" /> |
| <p className="text-sm font-semibold text-slate-700">Import en cours…</p> |
| {uploadingFileName && <p className="text-xs text-slate-400 truncate max-w-[200px]">{uploadingFileName}</p>} |
| </div> |
| ) : ( |
| <> |
| <FileSpreadsheet className="w-16 h-16 text-blue-500 mb-4 group-hover:scale-110 transition" /> |
| <p className="text-lg font-bold text-slate-900">Cliquez pour choisir un fichier</p> |
| <p className="text-slate-400 text-sm">ou glissez-déposez ici</p> |
| </> |
| )} |
| </div> |
| <input |
| type="file" |
| accept=".xlsx, .xls, .csv" |
| onChange={handleFileUpload} |
| disabled={uploading} |
| className="absolute inset-0 opacity-0 cursor-pointer" |
| /> |
| </label> |
| |
| <div className="p-6 bg-amber-50 rounded-2xl border border-amber-100"> |
| <p className="text-xs font-bold text-amber-700 uppercase mb-2 flex items-center gap-2"> |
| 💡 Conseil d'importation |
| </p> |
| <p className="text-sm text-amber-800 leading-relaxed font-medium"> |
| Assurez-vous que vos numéros incluent l'indicatif pays (ex: 221...) pour une meilleure délivrabilité. |
| </p> |
| </div> |
| </div> |
| ) : ( |
| <div className="text-center py-10 space-y-6"> |
| <div className="w-24 h-24 bg-emerald-100 rounded-[2.5rem] flex items-center justify-center mx-auto shadow-xl shadow-emerald-50"> |
| <CheckCircle2 className="w-12 h-12 text-emerald-600" /> |
| </div> |
| <div> |
| <h3 className="text-2xl font-black text-slate-900 mb-2">Importation Terminée !</h3> |
| <p className="text-slate-500 font-medium">L'IA a traité votre fichier avec succès.</p> |
| </div> |
| <div className="grid grid-cols-2 gap-4 max-w-xs mx-auto"> |
| <div className="p-4 bg-slate-50 rounded-2xl"> |
| <p className="text-2xl font-black text-slate-900">{importStats.created}</p> |
| <p className="text-[10px] font-bold text-slate-400 uppercase">Succès</p> |
| </div> |
| <div className="p-4 bg-red-50 rounded-2xl"> |
| <p className="text-2xl font-black text-red-600">{importStats.errors}</p> |
| <p className="text-[10px] font-bold text-red-400 uppercase">Échecs</p> |
| </div> |
| </div> |
| <button |
| onClick={() => { setShowImportModal(false); setImportStats(null); }} |
| className="w-full bg-slate-900 text-white py-4 rounded-2xl font-bold hover:bg-slate-800 transition" |
| > |
| Fermer et voir les contacts |
| </button> |
| </div> |
| )} |
| </div> |
| </div> |
| )} |
|
|
| {} |
| {selectedContactForAI && ( |
| <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-md flex items-center justify-center p-6 z-50"> |
| <div className="bg-white rounded-[2.5rem] shadow-2xl w-full max-w-2xl overflow-hidden animate-in fade-in zoom-in duration-300"> |
| {/* Modal Header */} |
| <div className="bg-indigo-600 p-8 text-white flex items-center justify-between"> |
| <div className="flex items-center gap-4"> |
| <div className="w-12 h-12 bg-white/20 rounded-2xl flex items-center justify-center"> |
| <Sparkles className="w-6 h-6" /> |
| </div> |
| <div> |
| <h3 className="text-xl font-black">AI Campaign Studio</h3> |
| <p className="text-indigo-100 text-sm font-medium">Génération pour {selectedContactForAI.name || 'Inconnu'}</p> |
| </div> |
| </div> |
| <button onClick={() => setSelectedContactForAI(null)} className="p-2 hover:bg-white/10 rounded-full transition"> |
| <Trash2 className="w-6 h-6" /> |
| </button> |
| </div> |
| |
| <div className="p-8"> |
| {generatingAI ? ( |
| <div className="py-20 text-center"> |
| <Loader2 className="w-16 h-16 animate-spin text-indigo-600 mx-auto mb-6" /> |
| <h4 className="text-xl font-bold text-slate-900 mb-2">L'IA analyse le contact...</h4> |
| <p className="text-slate-500 font-medium">Personnalisation du message en cours.</p> |
| </div> |
| ) : aiResult ? ( |
| <div className="space-y-6"> |
| {/* Objective Field */} |
| <div> |
| <label className="text-xs font-bold text-slate-400 uppercase mb-2 block tracking-widest">Objectif de la campagne</label> |
| <div className="flex gap-2"> |
| <input |
| className="flex-1 bg-slate-50 border-none rounded-xl p-3 font-medium text-slate-700 focus:ring-2 focus:ring-indigo-100" |
| value={campaignObjective} |
| onChange={(e) => setCampaignObjective(e.target.value)} |
| /> |
| <button |
| onClick={() => handleGenerateAI(selectedContactForAI)} |
| className="p-3 bg-indigo-50 text-indigo-600 rounded-xl hover:bg-indigo-100 transition" |
| title="Régénérer" |
| > |
| <RefreshCw className="w-5 h-5" /> |
| </button> |
| </div> |
| </div> |
| |
| {/* Reasoning */} |
| <div className="p-5 bg-amber-50 rounded-[1.5rem] border border-amber-100 flex gap-4"> |
| <div className="w-10 h-10 bg-amber-100 rounded-xl flex items-center justify-center flex-shrink-0"> |
| <BrainCircuit className="w-5 h-5 text-amber-600" /> |
| </div> |
| <div> |
| <p className="text-xs font-bold text-amber-700 uppercase mb-1">Raisonnement de l'IA ({aiResult.aiSource})</p> |
| <p className="text-sm text-amber-900 font-medium leading-relaxed">{aiResult.reasoning}</p> |
| </div> |
| </div> |
| |
| {/* Template Selection (Meta Compliance) */} |
| <div className="bg-slate-50 border border-slate-100 rounded-[1.5rem] p-5"> |
| <TemplateSelector |
| value={selectedTemplateName} |
| onChange={setSelectedTemplateName} |
| /> |
| </div> |
| |
| {/* Generated Message */} |
| <div className="relative group"> |
| <label className="text-xs font-bold text-slate-400 uppercase mb-2 block tracking-widest">Message Personnalisé (WhatsApp)</label> |
| <textarea |
| rows={6} |
| className="w-full bg-slate-50 border-none rounded-[1.5rem] p-6 font-medium text-slate-800 leading-relaxed focus:ring-4 focus:ring-indigo-50 transition" |
| value={aiResult.personalizedMessage} |
| onChange={(e) => setAiResult({...aiResult, personalizedMessage: e.target.value})} |
| /> |
| <button |
| onClick={() => { navigator.clipboard.writeText(aiResult.personalizedMessage); toast.success(t('contacts.message_copied')); }} |
| title="Copier le message" |
| className="absolute top-10 right-4 p-2 bg-white text-slate-400 rounded-lg hover:text-indigo-600 shadow-sm border border-slate-100 opacity-0 group-hover:opacity-100 transition" |
| > |
| <Copy className="w-4 h-4" /> |
| </button> |
| </div> |
| |
| <div className="flex gap-3"> |
| <button |
| onClick={() => { |
| const url = `https://wa.me/${selectedContactForAI.phoneNumber}?text=${encodeURIComponent(aiResult.personalizedMessage)}`; |
| window.open(url, '_blank'); |
| }} |
| className="flex-1 flex items-center justify-center gap-3 bg-indigo-600 text-white py-4 rounded-[1.5rem] font-bold hover:bg-indigo-700 transition shadow-xl shadow-indigo-100" |
| > |
| <Send className="w-5 h-5" /> |
| {selectedTemplateName ? `Envoyer via Template: ${selectedTemplateName}` : 'Envoyer via WhatsApp'} |
| </button> |
| <button |
| onClick={() => setSelectedContactForAI(null)} |
| className="px-8 bg-slate-100 text-slate-600 rounded-[1.5rem] font-bold hover:bg-slate-200 transition" |
| > |
| Annuler |
| </button> |
| </div> |
| </div> |
| ) : ( |
| <div className="text-center py-10"> |
| <button |
| onClick={() => handleGenerateAI(selectedContactForAI)} |
| className="bg-indigo-600 text-white px-8 py-4 rounded-2xl font-bold hover:bg-indigo-700 transition" |
| > |
| Lancer la génération |
| </button> |
| </div> |
| )} |
| </div> |
| </div> |
| </div> |
| )} |
|
|
| {} |
| {selectedContactIds.length > 0 && ( |
| <div className="fixed bottom-10 left-1/2 -translate-x-1/2 bg-slate-900 text-white px-8 py-5 rounded-[2rem] shadow-2xl flex items-center gap-8 z-40 animate-in slide-in-from-bottom-10 duration-500"> |
| <div className="flex items-center gap-3 pr-8 border-r border-slate-700"> |
| <div className="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center font-black text-sm"> |
| {selectedContactIds.length} |
| </div> |
| <p className="font-bold text-sm text-slate-300">Contacts sélectionnés</p> |
| </div> |
| |
| <div className="flex items-center gap-4"> |
| <div className="w-64"> |
| <TemplateSelector |
| value={selectedTemplateName} |
| onChange={setSelectedTemplateName} |
| /> |
| </div> |
| <button |
| className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-xl font-bold transition shadow-lg shadow-indigo-900/20" |
| onClick={handleBulkGenerate} |
| > |
| <Sparkles className="w-5 h-5" /> Générer Campagne IA |
| </button> |
| <button |
| onClick={handleBulkDelete} |
| className="flex items-center gap-2 bg-slate-800 hover:bg-red-900/40 text-slate-300 hover:text-red-400 px-6 py-3 rounded-xl font-bold transition" |
| > |
| <Trash2 className="w-5 h-5" /> Supprimer |
| </button> |
| <button |
| onClick={() => setSelectedContactIds([])} |
| className="text-slate-500 hover:text-white font-bold text-sm transition ml-4" |
| > |
| Annuler |
| </button> |
| </div> |
| </div> |
| )} |
|
|
| {} |
| {showBulkModal && ( |
| <div className="fixed inset-0 bg-slate-900/80 backdrop-blur-xl flex items-center justify-center p-6 z-50"> |
| <div className="bg-white rounded-[3rem] shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-300"> |
| {/* Header */} |
| <div className="p-8 border-b border-slate-100 flex items-center justify-between bg-slate-50/50"> |
| <div className="flex items-center gap-4"> |
| <div className="w-12 h-12 bg-indigo-600 rounded-2xl flex items-center justify-center text-white shadow-lg shadow-indigo-200"> |
| <BrainCircuit className="w-6 h-6" /> |
| </div> |
| <div> |
| <h3 className="text-2xl font-black text-slate-900">Génération de Campagne</h3> |
| <p className="text-slate-500 font-medium">{bulkProgress.current} / {bulkProgress.total} contacts traités</p> |
| </div> |
| </div> |
| {bulkProgress.status === 'completed' && ( |
| <button |
| onClick={() => setShowBulkModal(false)} |
| className="p-3 hover:bg-slate-200 rounded-full transition text-slate-400 hover:text-slate-900" |
| > |
| <Trash2 className="w-6 h-6" /> |
| </button> |
| )} |
| </div> |
| |
| {/* Progress Bar */} |
| <div className="h-2 bg-slate-100 w-full overflow-hidden"> |
| <div |
| className="h-full bg-indigo-600 transition-all duration-500 shadow-[0_0_20px_rgba(79,70,229,0.5)]" |
| style={{ width: `${(bulkProgress.current / bulkProgress.total) * 100}%` }} |
| /> |
| </div> |
| |
| {/* Results Content */} |
| <div className="flex-1 overflow-y-auto p-8 space-y-4"> |
| {bulkResults.length === 0 && bulkProgress.status === 'processing' && ( |
| <div className="py-20 text-center space-y-6"> |
| <Loader2 className="w-16 h-16 animate-spin text-indigo-600 mx-auto" /> |
| <p className="text-xl font-bold text-slate-900 italic">"L'intelligence artificielle rédige vos messages personnalisés..."</p> |
| </div> |
| )} |
| |
| {bulkResults.map((result, idx) => ( |
| <div key={idx} className="p-6 bg-slate-50 rounded-3xl border border-slate-100 flex items-start gap-6 hover:bg-white hover:shadow-xl hover:shadow-slate-100 transition duration-300 group"> |
| <div className="w-12 h-12 bg-white rounded-2xl flex items-center justify-center font-bold text-slate-400 border border-slate-100 group-hover:border-indigo-200 group-hover:text-indigo-600 transition"> |
| {idx + 1} |
| </div> |
| <div className="flex-1"> |
| <div className="flex items-center justify-between mb-3"> |
| <h4 className="font-bold text-slate-900">{result.contact.name || 'Inconnu'}</h4> |
| <span className="text-xs font-mono text-slate-400">+{result.contact.phoneNumber}</span> |
| </div> |
| <p className="text-sm text-slate-600 leading-relaxed bg-white p-4 rounded-2xl border border-slate-50"> |
| {result.personalizedMessage} |
| </p> |
| </div> |
| <div className="flex flex-col gap-2"> |
| <button |
| onClick={() => { |
| const url = `https://wa.me/${result.contact.phoneNumber}?text=${encodeURIComponent(result.personalizedMessage)}`; |
| window.open(url, '_blank'); |
| }} |
| className="p-3 bg-emerald-50 text-emerald-600 rounded-xl hover:bg-emerald-600 hover:text-white transition shadow-sm" |
| title="Envoyer WhatsApp" |
| > |
| <Send className="w-5 h-5" /> |
| </button> |
| <button |
| onClick={() => { |
| navigator.clipboard.writeText(result.personalizedMessage); |
| toast.success(t('contacts.copied')); |
| }} |
| className="p-3 bg-slate-100 text-slate-400 rounded-xl hover:bg-slate-200 hover:text-slate-900 transition" |
| title="Copier" |
| > |
| <Copy className="w-5 h-5" /> |
| </button> |
| </div> |
| </div> |
| ))} |
| </div> |
| |
| {/* Footer */} |
| {bulkProgress.status === 'completed' && ( |
| <div className="p-8 border-t border-slate-100 bg-slate-50/50 flex items-center justify-between"> |
| <div className="flex items-center gap-2 text-emerald-600 font-bold"> |
| <CheckCircle2 className="w-5 h-5" /> |
| {t('contacts.generation_completed')} |
| </div> |
| <div className="flex items-center gap-3"> |
| <button |
| onClick={() => setShowBulkModal(false)} |
| className="px-8 py-4 bg-slate-900 text-white rounded-2xl font-bold hover:bg-slate-800 transition shadow-xl" |
| > |
| Terminer |
| </button> |
| </div> |
| </div> |
| )} |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|