// 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 (

WhatsApp

); } const NoConv = () => (
Selecione uma conversa
Clique em uma conversa à esquerda para abrir o histórico e responder. As mensagens novas aparecem em tempo real.
); return (
{/* Header */}

WhatsApp

{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' : ''}

setNewConvModal(true)}>Nova conversa
{/* Container principal */}
{/* ────── COLUNA: LISTA DE CONVERSAS ────── */}
{/* Busca + filtros */}
setSearch(e.target.value)} placeholder="Buscar nome, número ou texto da conversa..." style={{ width: '100%', boxSizing: 'border-box', border: `1px solid ${DS.colors.border}`, borderRadius: DS.radius.sm, padding: '8px 12px 8px 36px', fontSize: 13, outline: 'none', background: '#fff', fontFamily: 'inherit', }} />
{[ ['all', 'Todas'], ['open', 'Aberto'], ['pending', 'Pendente'], ['resolved', 'Resolvido'], ].map(([val, label]) => ( ))}
{/* Lista */}
{filteredConvs.length === 0 ? (
{search || statusFilter !== 'all' ? 'Nenhuma conversa encontrada' : 'Nenhuma conversa ainda'}
) : filteredConvs.map(c => { const isActive = activeConv?.id === c.id; return (
setActiveConv(c)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', cursor: 'pointer', background: isActive ? '#FFF1E0' : 'transparent', borderLeft: isActive ? `3px solid ${ogColor}` : '3px solid transparent', borderBottom: `1px solid ${DS.colors.border}`, transition: 'background 0.15s', }} onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = '#F1F5F9'; }} onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}> {c.profile_pic_url ? ( e.target.style.display = 'none'} /> ) : ( )}
{c.contact_name || fmtPhone(c.phone)} 0 ? ogColor : DS.colors.textMuted, fontWeight: c.unread_count > 0 ? 700 : 500, flexShrink: 0 }}> {fmtTime(c.last_message_at)}
{c.last_message_from === 'us' && } {c.last_message_preview || '(sem mensagens)'} {c.unread_count > 0 && ( {c.unread_count} )}
); })}
{/* ────── COLUNAS: CHAT ATIVO + PAINEL CLIENTE ────── */} {!activeConv ? : (
{/* Header do chat */}
{activeConv.profile_pic_url ? ( ) : ( )}
{activeConv.contact_name || fmtPhone(activeConv.phone)}
{fmtPhone(activeConv.phone)}
setShowClientPanel(s => !s)} title={showClientPanel ? 'Ocultar info do cliente' : 'Mostrar info do cliente'}> {showClientPanel ? 'Fechar painel' : 'Cliente'} {activeConv.status !== 'resolved' ? ( setConvStatus(activeConv.id, 'resolved')}> Marcar resolvido ) : ( setConvStatus(activeConv.id, 'open')}> Reabrir )}
{/* Linha 2: Atribuido + Tags */}
{/* Atribuido */}
{showAssignDrop && (
assignConversation(null)} style={{ padding: '8px 12px', cursor: 'pointer', fontSize: 12, color: DS.colors.textSec, borderBottom: `1px solid ${DS.colors.border}` }} onMouseEnter={e => e.currentTarget.style.background = '#F8FAFC'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> Ninguém
{appUsers.map(u => (
assignConversation(u.id)} style={{ padding: '8px 12px', cursor: 'pointer', fontSize: 12, color: DS.colors.text, display: 'flex', alignItems: 'center', gap: 8, background: activeConv.assigned_to === u.id ? '#FFF7ED' : 'transparent' }} onMouseEnter={e => { if (activeConv.assigned_to !== u.id) e.currentTarget.style.background = '#F8FAFC'; }} onMouseLeave={e => { if (activeConv.assigned_to !== u.id) e.currentTarget.style.background = 'transparent'; }}> {u.name}
))}
)}
{/* Tags */}
{(activeConv.tags || []).map(t => ( {t} toggleTag(t)} style={{ cursor: 'pointer', opacity: 0.7 }}>× ))}
{showTagsDrop && (
{TAG_PRESETS.map(t => { const active = (activeConv.tags || []).includes(t); return (
{ toggleTag(t); }} style={{ padding: '6px 10px', cursor: 'pointer', fontSize: 11, fontWeight: 600, color: TAG_COLORS[t] || DS.colors.text, borderRadius: 4, background: active ? (TAG_COLORS[t] + '20') : 'transparent' }} onMouseEnter={e => { if (!active) e.currentTarget.style.background = '#F8FAFC'; }} onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}> {active ? '✓ ' : ''}{t}
); })}
)}
{/* Lista de mensagens */}
")' }}> {messages.length === 0 ? (
Sem mensagens ainda. Envie uma mensagem para começar.
) : messages.map((m, i) => { const isOut = m.direction === 'out'; const prevSameSide = i > 0 && messages[i - 1].direction === m.direction; return (
{m.type === 'image' && m.media_url && ( )} {m.type === 'audio' && m.media_url && (
); })}
{/* Input de envio */}
{/* Dropdown de templates rapidos */} {showTemplates && (
Modelos de resposta
{QUICK_REPLIES.map(t => (
{ setDraft(d => (d ? d + ' ' : '') + t.text); setShowTemplates(false); }} style={{ padding: '10px 14px', cursor: 'pointer', borderBottom: `1px solid ${DS.colors.border}` }} onMouseEnter={e => e.currentTarget.style.background = '#FFF7ED'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{t.label}
{t.text}
))}
)}