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([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [showImportModal, setShowImportModal] = useState(false); const [uploading, setUploading] = useState(false); const [uploadingFileName, setUploadingFileName] = useState(''); const [importStats, setImportStats] = useState(null); // AI Campaign State const [selectedContactForAI, setSelectedContactForAI] = useState(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(''); // Bulk Selection & Generation State const [selectedContactIds, setSelectedContactIds] = useState([]); const [showBulkModal, setShowBulkModal] = useState(false); const [bulkProgress, setBulkProgress] = useState({ current: 0, total: 0, status: 'idle' }); const [bulkResults, setBulkResults] = useState([]); const [activeCount, setActiveCount] = useState(null); const [showFilters, setShowFilters] = useState(false); // Tags state const [availableTags, setAvailableTags] = useState([]); const [activeTagFilter, setActiveTagFilter] = useState([]); const [tagInputs, setTagInputs] = useState>({}); const [savingTags, setSavingTags] = useState>({}); const fetchAvailableTags = async () => { if (!token || !selectedOrgId) return; try { const data = await api.get(`/v1/organizations/${selectedOrgId}/contacts/tags`, token); setAvailableTags(data.tags ?? []); } catch { /* non-blocking */ } }; 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) => { 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) { // api.delete might return true/ok depending on implementation 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); // Search in dynamic attributes const inAttributes = c.attributes && Object.values(c.attributes).some(val => String(val).toLowerCase().includes(searchLower) ); return inName || inPhone || inAttributes; }); return (
{/* Header Area */}

{t('contacts.title')}

{t('contacts.subtitle')}

{/* Stats Bar */}

Total Contacts

{contacts.length}

Actifs (24h)

{activeCount ?? '—'}

Segments

{searchQuery ? 1 : contacts.length > 0 ? 1 : 0}

{/* Filters & Table */}
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" />
{/* Tag filter chips */} {(showFilters || activeTagFilter.length > 0) && availableTags.length > 0 && (
Tags : {availableTags.map(tag => ( ))} {activeTagFilter.length > 0 && ( )}
)} {showFilters && availableTags.length === 0 && (

Aucun tag créé. Ajoutez des tags sur vos contacts pour les segmenter.

)}
{loading ? ( ) : filteredContacts.length === 0 ? ( ) : filteredContacts.map(contact => ( {/* Tags column */} ))}
0 && selectedContactIds.length === filteredContacts.length} onChange={toggleSelectAll} /> Client Téléphone Tags Attributs Actions

Chargement de la base de données...

{t('contacts.no_contacts')}

{t('contacts.subtitle')}

toggleSelectContact(contact.id)} />
{contact.name?.[0] || '?'}

{contact.name || 'Inconnu'}

Importé le {new Date(contact.createdAt).toLocaleDateString()}

+{contact.phoneNumber}
{(contact.tags ?? []).map(tag => ( #{tag} ))}
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] && }
{contact.attributes && Object.entries(contact.attributes).slice(0, 3).map(([key, val]) => ( {key}: {String(val)} ))} {Object.keys(contact.attributes || {}).length > 3 && ( +{Object.keys(contact.attributes).length - 3} plus )}
{/* Import Modal */} {showImportModal && (

Importation Excel

{!importStats ? (

Veuillez uploader un fichier .xlsx ou .csv. L'IA détectera automatiquement les colonnes de noms et de numéros.

💡 Conseil d'importation

Assurez-vous que vos numéros incluent l'indicatif pays (ex: 221...) pour une meilleure délivrabilité.

) : (

Importation Terminée !

L'IA a traité votre fichier avec succès.

{importStats.created}

Succès

{importStats.errors}

Échecs

)}
)} {/* AI Generation Modal */} {selectedContactForAI && (
{/* Modal Header */}

AI Campaign Studio

Génération pour {selectedContactForAI.name || 'Inconnu'}

{generatingAI ? (

L'IA analyse le contact...

Personnalisation du message en cours.

) : aiResult ? (
{/* Objective Field */}
setCampaignObjective(e.target.value)} />
{/* Reasoning */}

Raisonnement de l'IA ({aiResult.aiSource})

{aiResult.reasoning}

{/* Template Selection (Meta Compliance) */}
{/* Generated Message */}