// WhatsApp Module — integracao via Z-API // Layout estilo WhatsApp Web: lista de conversas a esquerda, chat ao centro. // Mensagens entram/saem em tempo real via Supabase Realtime. // Templates de resposta rapida (proxima iteracao: cadastrar via tela admin) const QUICK_REPLIES = [ { label: 'Saudação', text: 'Olá! Tudo bem? Aqui é a Promoup, em que posso te ajudar?' }, { label: 'Vou enviar orçamento', text: 'Já estou montando o orçamento e te envio em instantes 🙏' }, { label: 'Retorno em breve', text: 'Recebido! Vou consultar internamente e te dou um retorno em breve.' }, { label: 'Confirmar evento', text: 'Oi! Estou passando para confirmar nosso evento. Está tudo certo para seguir?' }, { label: 'Agradecimento', text: 'Muito obrigada pelo contato! Qualquer coisa estou à disposição. 😊' }, { label: 'Pedir documentos', text: 'Para seguir com a contratação, você consegue me enviar: CNPJ/CPF, endereço completo e dados para nota fiscal? 📋' }, ]; // Normaliza telefone para 13 digitos com codigo do pais (formato Z-API) function normalizePhoneForZapi(p) { let digits = (p || '').replace(/[^0-9]/g, ''); // Adiciona 9 no celular antigo if (digits.length === 10) digits = digits.slice(0, 2) + '9' + digits.slice(2); if (digits.length === 11) digits = '55' + digits; return digits; } function WhatsApp({ currentUser, onNavigate }) { const [conversations, setConversations] = React.useState([]); const [activeConv, setActiveConv] = React.useState(null); const [messages, setMessages] = React.useState([]); const [loading, setLoading] = React.useState(true); const [search, setSearch] = React.useState(''); const [statusFilter, setStatusFilter] = React.useState('all'); const [draft, setDraft] = React.useState(''); const [sending, setSending] = React.useState(false); const [toast, setToast] = React.useState(null); const [newConvModal, setNewConvModal] = React.useState(false); const [uploading, setUploading] = React.useState(false); const [showTemplates, setShowTemplates] = React.useState(false); const [appUsers, setAppUsers] = React.useState([]); const [showAssignDrop, setShowAssignDrop] = React.useState(false); const [showTagsDrop, setShowTagsDrop] = React.useState(false); const [showClientPanel, setShowClientPanel] = React.useState(true); const [clientInfo, setClientInfo] = React.useState(null); const [clientBudgets, setClientBudgets] = React.useState([]); const [clientEvents, setClientEvents] = React.useState([]); const messagesEndRef = React.useRef(null); const fileInputRef = React.useRef(null); const showToast = (msg, type = 'success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 3000); }; // ───────── Carga inicial ───────── const fetchConversations = React.useCallback(async () => { const { data } = await sb.from('whatsapp_conversations') .select('id, phone, contact_name, profile_pic_url, client_id, last_message_at, last_message_preview, last_message_from, unread_count, status, assigned_to, tags') .order('last_message_at', { ascending: false }) .limit(200); setConversations(data || []); setLoading(false); }, []); React.useEffect(() => { fetchConversations(); }, [fetchConversations]); // Carrega usuarios para atribuicao React.useEffect(() => { sb.from('app_users').select('id, name, email, department').order('name').then(({ data }) => { setAppUsers(data || []); }); }, []); const TAG_PRESETS = ['Comercial', 'Suporte', 'Produção', 'Financeiro', 'Lead Quente', 'Aguardando']; const TAG_COLORS = { 'Comercial': '#10B981', 'Suporte': '#3B82F6', 'Produção': '#F59E0B', 'Financeiro': '#8B5CF6', 'Lead Quente': '#EF4444', 'Aguardando': '#64748B' }; const assignConversation = async (userId) => { if (!activeConv) return; await sb.from('whatsapp_conversations').update({ assigned_to: userId }).eq('id', activeConv.id); setConversations(prev => prev.map(c => c.id === activeConv.id ? { ...c, assigned_to: userId } : c)); setActiveConv(prev => ({ ...prev, assigned_to: userId })); setShowAssignDrop(false); showToast(userId ? `Atribuído a ${appUsers.find(u => u.id === userId)?.name || 'usuário'}` : 'Atribuição removida'); }; const toggleTag = async (tag) => { if (!activeConv) return; const currentTags = activeConv.tags || []; const newTags = currentTags.includes(tag) ? currentTags.filter(t => t !== tag) : [...currentTags, tag]; await sb.from('whatsapp_conversations').update({ tags: newTags }).eq('id', activeConv.id); setConversations(prev => prev.map(c => c.id === activeConv.id ? { ...c, tags: newTags } : c)); setActiveConv(prev => ({ ...prev, tags: newTags })); }; // ───────── Realtime para conversas + mensagens ───────── React.useEffect(() => { const channel = sb.channel('wpp-realtime') .on('postgres_changes', { event: '*', schema: 'public', table: 'whatsapp_conversations' }, payload => { if (payload.eventType === 'INSERT' || payload.eventType === 'UPDATE') { setConversations(prev => { const idx = prev.findIndex(c => c.id === payload.new.id); if (idx >= 0) { const next = [...prev]; next[idx] = { ...next[idx], ...payload.new }; return next.sort((a, b) => new Date(b.last_message_at) - new Date(a.last_message_at)); } return [payload.new, ...prev]; }); } else if (payload.eventType === 'DELETE') { setConversations(prev => prev.filter(c => c.id !== payload.old.id)); } }) .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'whatsapp_messages' }, payload => { const msg = payload.new; // Se a conversa ativa eh a dele, adiciona na lista setActiveConv(curr => { if (curr && curr.id === msg.conversation_id) { setMessages(prev => { if (prev.some(m => m.id === msg.id)) return prev; return [...prev, msg]; }); } return curr; }); }) .subscribe(); return () => { sb.removeChannel(channel); }; }, []); // ───────── Carrega dados do cliente vinculado ───────── React.useEffect(() => { setClientInfo(null); setClientBudgets([]); setClientEvents([]); if (!activeConv?.client_id) return; (async () => { const [clientRes, budgetsRes, eventsRes] = await Promise.all([ sb.from('clients').select('*').eq('id', activeConv.client_id).maybeSingle(), sb.from('budgets').select('id, code, event_name, value, status, issued_date').eq('client_name', '__none__').order('issued_date', { ascending: false }).limit(5), sb.from('events').select('id, name, type, event_date, status').eq('client_name', '__none__').order('event_date', { ascending: false }).limit(5), ]); const c = clientRes.data; setClientInfo(c); if (c) { const [budByName, evByName] = await Promise.all([ sb.from('budgets').select('id, code, event_name, value, status, issued_date').eq('client_name', c.name).order('issued_date', { ascending: false }).limit(5), sb.from('events').select('id, name, type, event_date, status').eq('client_name', c.name).order('event_date', { ascending: false }).limit(5), ]); setClientBudgets(budByName.data || []); setClientEvents(evByName.data || []); } })(); }, [activeConv?.id, activeConv?.client_id]); // ───────── Carrega mensagens quando seleciona conversa ───────── React.useEffect(() => { if (!activeConv) { setMessages([]); return; } sb.from('whatsapp_messages') .select('id, conversation_id, direction, type, body, media_url, media_mime, sender_user_id, status, created_at') .eq('conversation_id', activeConv.id) .order('created_at', { ascending: true }) .limit(500) .then(({ data }) => setMessages(data || [])); // Zera unread if (activeConv.unread_count > 0) { sb.from('whatsapp_conversations').update({ unread_count: 0 }).eq('id', activeConv.id).then(() => { setConversations(prev => prev.map(c => c.id === activeConv.id ? { ...c, unread_count: 0 } : c)); }); } }, [activeConv]); // ───────── Auto-scroll pro final ───────── React.useEffect(() => { if (messagesEndRef.current) messagesEndRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' }); }, [messages]); // ───────── Enviar mensagem ───────── const handleSend = async () => { if (!draft.trim() || !activeConv) return; const text = draft.trim(); setSending(true); setDraft(''); try { // sb.functions.invoke cuida do apikey + Authorization automaticamente const { data, error } = await sb.functions.invoke('whatsapp-send', { body: { conversation_id: activeConv.id, type: 'text', body: text, }, }); if (error) { showToast('Erro ao enviar: ' + (error.message || 'tente novamente'), 'error'); setDraft(text); } } catch (e) { showToast('Erro: ' + e.message, 'error'); setDraft(text); } finally { setSending(false); } }; // ───────── Anexar e enviar arquivo (foto, audio, PDF) ───────── const handleAttachFile = async (e) => { const file = e.target.files?.[0]; e.target.value = ''; // permite re-anexo do mesmo arquivo if (!file || !activeConv) return; if (file.size > 16 * 1024 * 1024) { showToast('Arquivo grande demais (máx 16MB)', 'error'); return; } setUploading(true); try { // 1) Upload pro bucket Supabase const ext = file.name.split('.').pop() || 'bin'; const path = `conv_${activeConv.id}/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`; const { error: upErr } = await sb.storage.from('whatsapp-media').upload(path, file, { contentType: file.type, upsert: false, }); if (upErr) throw upErr; const { data: { publicUrl } } = sb.storage.from('whatsapp-media').getPublicUrl(path); // 2) Determina tipo (image / audio / document) let msgType = 'document'; if (file.type.startsWith('image/')) msgType = 'image'; else if (file.type.startsWith('audio/')) msgType = 'audio'; // 3) Chama whatsapp-send com a URL publica const { error } = await sb.functions.invoke('whatsapp-send', { body: { conversation_id: activeConv.id, type: msgType, media_url: publicUrl, media_filename: file.name, body: draft.trim() || null, // caption opcional (texto que estiver digitado) }, }); if (error) { showToast('Erro ao enviar: ' + (error.message || 'tente novamente'), 'error'); return; } setDraft(''); // limpa caption depois de enviar showToast('Enviado!'); } catch (e) { showToast('Erro: ' + e.message, 'error'); } finally { setUploading(false); } }; // ───────── Mudar status / fechar conversa ───────── const setConvStatus = async (id, newStatus) => { await sb.from('whatsapp_conversations').update({ status: newStatus }).eq('id', id); setConversations(prev => prev.map(c => c.id === id ? { ...c, status: newStatus } : c)); if (activeConv?.id === id) setActiveConv(prev => ({ ...prev, status: newStatus })); showToast(newStatus === 'resolved' ? 'Conversa marcada como resolvida' : 'Conversa reaberta'); }; // ───────── Permissoes: Comercial ve so as conversas atribuidas a ele ───────── const visibleConvs = React.useMemo(() => { if (!currentUser) return conversations; // Admin (role) ve tudo. Setores admin/financeiro/sdr/atendimento veem tudo. if (currentUser.role === 'admin') return conversations; if (['admin','financeiro','sdr','atendimento'].includes(currentUser.department)) return conversations; // Comercial ve so as conversas atribuidas a ele OU sem atribuicao if (currentUser.department === 'comercial') { return conversations.filter(c => c.assigned_to === currentUser.id || !c.assigned_to); } // Producao: ve tudo (eles tambem podem precisar acompanhar) return conversations; }, [conversations, currentUser]); // ───────── Filtros e busca (inclui busca em conteudo de mensagens) ───────── const [searchedInMsgs, setSearchedInMsgs] = React.useState(new Set()); React.useEffect(() => { const q = search.trim(); if (q.length < 3) { setSearchedInMsgs(new Set()); return; } const t = setTimeout(async () => { const { data } = await sb.from('whatsapp_messages').select('conversation_id').ilike('body', `%${q}%`).limit(200); setSearchedInMsgs(new Set((data || []).map(m => m.conversation_id))); }, 350); return () => clearTimeout(t); }, [search]); const filteredConvs = React.useMemo(() => { const q = search.trim().toLowerCase(); return visibleConvs.filter(c => { if (statusFilter !== 'all' && c.status !== statusFilter) return false; if (!q) return true; const matchHeader = (c.contact_name || '').toLowerCase().includes(q) || (c.phone || '').includes(q); const matchBody = searchedInMsgs.has(c.id); return matchHeader || matchBody; }); }, [visibleConvs, search, statusFilter, searchedInMsgs]); // ───────── Helpers de visual ───────── const fmtTime = (iso) => { if (!iso) return ''; const d = new Date(iso); const today = new Date(); if (d.toDateString() === today.toDateString()) { return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); } const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); if (d.toDateString() === yesterday.toDateString()) return 'Ontem'; return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' }); }; const fmtPhone = (p) => { if (!p) return ''; const clean = p.replace(/\D/g, ''); if (clean.length === 13) return `+${clean.slice(0, 2)} (${clean.slice(2, 4)}) ${clean.slice(4, 9)}-${clean.slice(9)}`; if (clean.length === 12) return `+${clean.slice(0, 2)} (${clean.slice(2, 4)}) ${clean.slice(4, 8)}-${clean.slice(8)}`; return p; }; // ───────── Estilos ───────── const wppBg = '#E5DDD5'; // mesmo bg do WhatsApp const ogColor = '#EA6B00'; const navyColor = '#12085C'; // ───────────────────────────────── // RENDER // ───────────────────────────────── if (loading) { return (
{conversations.length} conversa{conversations.length !== 1 ? 's' : ''} · {conversations.reduce((s, c) => s + (c.unread_count || 0), 0)} não lida{conversations.reduce((s, c) => s + (c.unread_count || 0), 0) !== 1 ? 's' : ''}