/* CRM — caixa de entrada operacional. */ const { crmInboxAppointmentDate, crmInboxPhone, crmInboxContactName, crmInboxInitials, crmInboxStatusLabels, crmInboxRoleLabels, crmInboxArrangeMessages, crmInboxReplyPreview, CrmConversationItem, CrmKanbanCard, CrmMessageBubble, CrmNewConversationModal, } = window.CrmInboxParts; function crmInboxFriendlyError(error, fallback) { const status = Number(error && error.status); const raw = String((error && error.message) || ''); if (status === 403) { return 'Você não tem permissão para realizar esta ação.'; } if (/24 horas|modelo aprovado/i.test(raw)) { return 'Para enviar uma mensagem livre, o cliente precisa ter iniciado a conversa nas últimas 24 horas. Fora desse período, use uma mensagem previamente aprovada.'; } if (/Aguarde um minuto/i.test(raw)) { return 'Aguarde um minuto antes de enviar novas mensagens.'; } if (/telefone|número/i.test(raw) && /DDD|país|válid|inválid/i.test(raw)) { return 'Confira o número do WhatsApp, incluindo país e DDD.'; } if (/Áudios não aceitam legenda|nome do atendente|Digite uma mensagem/i.test(raw)) { return raw; } if (/Meta|modelo|arquivo|mídia|cabeçalho|destinatário|autorização do canal/i.test(raw)) { return raw; } return fallback; } function CrmInboxScreen({ user }) { const [payload, setPayload] = React.useState(null); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(''); const [notice, setNotice] = React.useState(''); const [channelBusy, setChannelBusy] = React.useState(''); const [settingsOpen, setSettingsOpen] = React.useState(false); const [accessOpen, setAccessOpen] = React.useState(false); const [settingsView, setSettingsView] = React.useState(false); const [busyUserId, setBusyUserId] = React.useState(0); const [savedUserId, setSavedUserId] = React.useState(0); const [conversations, setConversations] = React.useState([]); const [counts, setCounts] = React.useState({ all: 0, open: 0, pending: 0, resolved: 0, unread: 0, awaiting: 0 }); const [conversationLoading, setConversationLoading] = React.useState(false); const [filter, setFilter] = React.useState('all'); const [assignedFilter, setAssignedFilter] = React.useState('all'); const [search, setSearch] = React.useState(''); const [selectedId, setSelectedId] = React.useState(0); const [selectedConversation, setSelectedConversation] = React.useState(null); const [messages, setMessages] = React.useState([]); const [threadLoading, setThreadLoading] = React.useState(false); const [conversationBusy, setConversationBusy] = React.useState(false); const [messageBody, setMessageBody] = React.useState(''); const [messageBusy, setMessageBusy] = React.useState(false); const [attachment, setAttachment] = React.useState(null); const [replyingTo, setReplyingTo] = React.useState(null); const [senderEditorOpen, setSenderEditorOpen] = React.useState(false); const [senderNameDraft, setSenderNameDraft] = React.useState(''); const [senderIdentityBusy, setSenderIdentityBusy] = React.useState(false); const [contextOpen, setContextOpen] = React.useState(false); const [inboxView, setInboxView] = React.useState(() => window.localStorage.getItem('crmInboxView') === 'board' ? 'board' : 'list'); const [boardBusyId, setBoardBusyId] = React.useState(0); const [boardDragId, setBoardDragId] = React.useState(0); const [boardDropStatus, setBoardDropStatus] = React.useState(''); const messageViewportRef = React.useRef(null); const attachmentInputRef = React.useRef(null); const messageSendingRef = React.useRef(false); const [newConversationOpen, setNewConversationOpen] = React.useState(false); const [contactQuery, setContactQuery] = React.useState(''); const [contacts, setContacts] = React.useState([]); const [contactsLoading, setContactsLoading] = React.useState(false); const [newPhone, setNewPhone] = React.useState(''); const [newBusy, setNewBusy] = React.useState(false); const [newConversationError, setNewConversationError] = React.useState(''); const [templates, setTemplates] = React.useState([]); const [templatesLoading, setTemplatesLoading] = React.useState(false); const [templateKey, setTemplateKey] = React.useState(''); const [templateParameters, setTemplateParameters] = React.useState({}); const [templateMedia, setTemplateMedia] = React.useState(null); React.useEffect(() => { const navigate = (event) => { const target = String((event.detail && event.detail.target) || 'inbox'); if (target === 'settings' || target === 'team' || target === 'channel') { setSettingsView(true); setSettingsOpen(target === 'settings' || target === 'channel'); setAccessOpen(target === 'settings' || target === 'team'); const selector = target === 'team' ? '.crm-access-collapsible' : '.crm-channel-settings'; window.setTimeout(() => document.querySelector(selector)?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 0); return; } setSettingsView(false); setSettingsOpen(false); if (target === 'contacts') { setContactQuery(''); setNewPhone(''); setNewConversationOpen(true); return; } window.setTimeout(() => document.querySelector('.crm-inbox-shell')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 0); }; window.addEventListener('crm:navigate', navigate); return () => window.removeEventListener('crm:navigate', navigate); }, []); const crm = (payload && payload.crm) || {}; const senderIdentity = crm.senderIdentity || {}; const senderDisplayName = senderIdentity.displayName || user.crmDisplayName || user.name || user.login || 'Atendimento'; const messageMaxLength = Math.max(1, (attachment ? 1024 : 4096) - String(senderDisplayName).length - 4); const access = crm.access || user.crmAccess || {}; const users = (payload && payload.users) || []; const assignees = (payload && payload.assignees) || []; const whatsapp = payload && payload.channels && payload.channels.whatsapp; const whatsappConnected = !!(whatsapp && whatsapp.status === 'connected'); const whatsappCoexistence = whatsappConnected && whatsapp.keepsMobileNumber === true; const channelSetupReady = !!crm.channelsReady; const connectionId = Number(whatsapp ? whatsapp.id : 0); const enabledUsersCount = users.filter((item) => item.crmEnabled).length; const arrangedMessages = React.useMemo(() => crmInboxArrangeMessages(messages), [messages]); const messagesByProviderId = React.useMemo(() => { const index = new Map(); messages.forEach((message) => { if (message.providerMessageId) index.set(message.providerMessageId, message); }); return index; }, [messages]); const boardColumns = React.useMemo(() => { const columns = { awaiting: [], open: [], pending: [], resolved: [] }; conversations.forEach((conversation) => { if (conversation.lastMessageDirection === 'inbound') columns.awaiting.push(conversation); else if (conversation.status === 'resolved') columns.resolved.push(conversation); else if (conversation.status === 'pending') columns.pending.push(conversation); else columns.open.push(conversation); }); return columns; }, [conversations]); const loadStatus = React.useCallback(async () => { setLoading(true); setError(''); try { setPayload(await window.API.crm.status()); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível carregar o CRM.')); } finally { setLoading(false); } }, []); React.useEffect(() => { loadStatus(); }, [loadStatus]); const loadConversations = React.useCallback(async (silent = false) => { if (!connectionId || !whatsappConnected) { setConversations([]); setCounts({ all: 0, open: 0, pending: 0, resolved: 0, unread: 0, awaiting: 0 }); return; } if (!silent) setConversationLoading(true); try { const result = await window.API.crm.conversations({ status: inboxView === 'board' || filter === 'unread' ? 'all' : filter, q: search.trim(), unread: inboxView !== 'board' && filter === 'unread', assigned: assignedFilter, limit: 150, }); setConversations(result.conversations || []); setCounts(result.counts || { all: 0, open: 0, pending: 0, resolved: 0, unread: 0, awaiting: 0 }); } catch (err) { if (!silent) setError(crmInboxFriendlyError(err, 'Não foi possível carregar as conversas.')); } finally { if (!silent) setConversationLoading(false); } }, [connectionId, whatsappConnected, filter, search, assignedFilter, inboxView]); const loadConversation = React.useCallback(async (conversationId, markRead = false, silent = false) => { if (!conversationId) return; if (!silent) setThreadLoading(true); try { const result = await window.API.crm.conversation(conversationId, 250); let conversation = result.conversation || null; setMessages(result.messages || []); if (markRead && conversation && conversation.unreadCount > 0) { const updated = await window.API.crm.updateConversation(conversationId, { markRead: true }); conversation = updated.conversation || conversation; setConversations((current) => current.map((item) => item.id === conversationId ? { ...item, unreadCount: 0 } : item)); setCounts((current) => ({ ...current, unread: Math.max(0, current.unread - 1) })); } setSelectedConversation(conversation); } catch (err) { if (Number(err && err.status) === 404) { setSelectedId((current) => current === Number(conversationId) ? 0 : current); setSelectedConversation(null); setMessages([]); setConversations((current) => current.filter((item) => Number(item.id) !== Number(conversationId))); return; } if (!silent) setError(crmInboxFriendlyError(err, 'Não foi possível abrir a conversa.')); } finally { if (!silent) setThreadLoading(false); } }, []); React.useEffect(() => { if (!whatsappConnected) return undefined; const timer = window.setTimeout(() => loadConversations(), search.trim() ? 260 : 0); return () => window.clearTimeout(timer); }, [whatsappConnected, loadConversations]); React.useEffect(() => { if (inboxView === 'board' || selectedId || conversations.length === 0) return; if (!window.matchMedia || window.matchMedia('(min-width: 721px)').matches) { const firstId = Number(conversations[0].id); setSelectedId(firstId); loadConversation(firstId, true); } }, [conversations, selectedId, loadConversation, inboxView]); React.useEffect(() => { if (!whatsappConnected) return undefined; const timer = window.setInterval(() => { loadConversations(true); if (selectedId) loadConversation(selectedId, true, true); }, 5000); return () => window.clearInterval(timer); }, [whatsappConnected, selectedId, loadConversations, loadConversation]); React.useEffect(() => { const viewport = messageViewportRef.current; if (viewport) { viewport.scrollTop = viewport.scrollHeight; } }, [messages.length, selectedId]); React.useEffect(() => { if (!newConversationOpen) return undefined; const timer = window.setTimeout(async () => { setContactsLoading(true); try { const result = await window.API.crm.contacts(contactQuery, 30); setContacts(result.contacts || []); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível buscar os clientes.')); } finally { setContactsLoading(false); } }, contactQuery.trim() ? 240 : 0); return () => window.clearTimeout(timer); }, [newConversationOpen, contactQuery]); React.useEffect(() => { if (!newConversationOpen) return undefined; let active = true; setTemplatesLoading(true); window.API.crm.messageTemplates() .then((result) => { if (active) setTemplates(result.templates || []); }) .catch((err) => { if (active) setError(crmInboxFriendlyError(err, 'Não foi possível carregar os modelos aprovados da Meta.')); }) .finally(() => { if (active) setTemplatesLoading(false); }); return () => { active = false; }; }, [newConversationOpen]); const chooseConversation = (conversationId) => { setReplyingTo(null); setSelectedId(conversationId); setSelectedConversation(null); setMessages([]); loadConversation(conversationId, true); }; const changeInboxView = (view) => { const nextView = view === 'board' ? 'board' : 'list'; setInboxView(nextView); window.localStorage.setItem('crmInboxView', nextView); if (nextView === 'board') { setReplyingTo(null); setSelectedId(0); setSelectedConversation(null); setMessages([]); setContextOpen(false); } }; const openBoardConversation = (conversationId) => { changeInboxView('list'); chooseConversation(conversationId); }; const moveBoardConversation = async (conversationId, status) => { if (!['open', 'pending', 'resolved'].includes(status) || boardBusyId) return; const previous = conversations; setBoardBusyId(conversationId); setError(''); setConversations((current) => current.map((item) => Number(item.id) === Number(conversationId) ? { ...item, status, unreadCount: status === 'open' ? 0 : item.unreadCount } : item)); try { await window.API.crm.updateConversation(conversationId, { status, markRead: status === 'open', }); setNotice(status === 'resolved' ? 'Conversa concluída.' : status === 'pending' ? 'Conversa movida para pendentes.' : 'Conversa em atendimento.'); await loadConversations(true); } catch (err) { setConversations(previous); setError(crmInboxFriendlyError(err, 'Não foi possível mover a conversa.')); } finally { setBoardBusyId(0); setBoardDragId(0); setBoardDropStatus(''); } }; const updateConversation = async (data, successMessage = '') => { if (!selectedId) return; setConversationBusy(true); setError(''); try { const result = await window.API.crm.updateConversation(selectedId, data); const updated = result.conversation; setSelectedConversation(updated); setConversations((current) => current.map((item) => item.id === selectedId ? { ...item, ...updated } : item)); if (successMessage) setNotice(successMessage); loadConversations(true); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível atualizar a conversa.')); } finally { setConversationBusy(false); } }; const sendMessage = async (event) => { event.preventDefault(); if (messageSendingRef.current || !selectedId || (!messageBody.trim() && !attachment)) return; const submittedBody = messageBody; const submittedAttachment = attachment; const submittedReply = replyingTo; messageSendingRef.current = true; setMessageBusy(true); setMessageBody(''); setAttachment(null); setReplyingTo(null); if (attachmentInputRef.current) attachmentInputRef.current.value = ''; setError(''); setNotice(''); try { const result = submittedAttachment ? await window.API.crm.sendMedia( selectedId, submittedAttachment, submittedBody, submittedReply ? submittedReply.providerMessageId : '' ) : await window.API.crm.sendMessage( '', submittedBody, selectedId, submittedReply ? submittedReply.providerMessageId : '' ); if (result.message) { setMessages((current) => [ ...current.filter((item) => item.id !== result.message.id), result.message, ]); } if (result.conversation) setSelectedConversation(result.conversation); loadConversations(true); window.setTimeout(() => loadConversation(selectedId, true, true), 1200); } catch (err) { const rejectedBeforeSend = Number(err && err.status) >= 400 && Number(err && err.status) < 500; if (rejectedBeforeSend) { setMessageBody((current) => current || submittedBody); setAttachment((current) => current || submittedAttachment); setReplyingTo((current) => current || submittedReply); setError(crmInboxFriendlyError(err, 'Não foi possível enviar a mensagem.')); } else { // Em timeout/erro 5xx a Meta pode já ter aceitado a mensagem. O campo // permanece vazio para impedir um segundo envio acidental. setMessageBody(''); setAttachment(null); setReplyingTo(null); setError('Não foi possível confirmar a atualização da tela. Verifique o histórico antes de tentar novamente.'); window.setTimeout(() => loadConversation(selectedId, true, true), 900); } } finally { messageSendingRef.current = false; setMessageBusy(false); } }; const saveSenderIdentity = async () => { if (senderIdentityBusy) return; setSenderIdentityBusy(true); setError(''); try { const result = await window.API.crm.setSenderIdentity(senderNameDraft); setPayload((current) => ({ ...(current || {}), crm: { ...((current && current.crm) || {}), senderIdentity: result.senderIdentity, }, })); setSenderEditorOpen(false); setNotice(`As mensagens serão identificadas como ${result.senderIdentity.displayName}.`); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível alterar o nome do atendimento.')); } finally { setSenderIdentityBusy(false); } }; const sendNewConversation = async () => { if (!newPhone.trim() || !templateKey) return; setNewBusy(true); setError(''); setNewConversationError(''); try { const separator = templateKey.lastIndexOf('|'); const result = await window.API.crm.sendTemplate( newPhone, templateKey.slice(0, separator), templateKey.slice(separator + 1), templateParameters, templateMedia ); const conversationId = Number( (result.conversation && result.conversation.id) || (result.message && result.message.conversationId) || 0 ); setNewConversationOpen(false); setContactQuery(''); setContacts([]); setNewPhone(''); setTemplateKey(''); setTemplateParameters({}); setTemplateMedia(null); await loadConversations(true); if (conversationId) { setSelectedId(conversationId); await loadConversation(conversationId, true); } } catch (err) { const friendlyError = crmInboxFriendlyError(err, 'Não foi possível iniciar a conversa.'); setNewConversationError(friendlyError); } finally { setNewBusy(false); } }; const connectWhatsApp = async () => { let signupWaiter = null; setChannelBusy('connect'); setError(''); setNotice(''); try { const start = await window.API.crm.startOnboarding('whatsapp'); const publicMeta = start.meta || {}; const onboarding = start.onboarding || {}; const featureType = String(publicMeta.embeddedSignupFeatureType || ''); if ( publicMeta.keepsMobileNumber !== true || onboarding.keepsMobileNumber !== true || onboarding.featureType !== featureType || featureType !== 'whatsapp_business_app_onboarding' ) { throw new Error('Não foi possível iniciar a conexão.'); } const FB = await window.loadCrmMetaSdk(publicMeta.appId, publicMeta.graphVersion); signupWaiter = window.createEmbeddedSignupWaiter(); const code = await new Promise((resolve, reject) => { FB.login((response) => { const authCode = response && response.authResponse && response.authResponse.code; if (authCode) resolve(String(authCode)); else reject(new Error('Não foi possível autorizar a conexão.')); }, { config_id: String(publicMeta.configId), response_type: 'code', override_default_response_type: true, extras: { setup: {}, featureType, sessionInfoVersion: '3', }, }); }); const session = await signupWaiter.promise; if (!session) throw new Error('A conexão foi interrompida.'); const result = await window.API.crm.completeOnboarding({ state: onboarding.state || '', code, ...session, }); setPayload((current) => ({ ...(current || {}), channels: { ...((current && current.channels) || {}), whatsapp: result.connection }, })); setNotice('WhatsApp conectado. O número continua disponível no celular e também pode ser atendido pelo CRM.'); setSettingsOpen(false); } catch (err) { if (signupWaiter) signupWaiter.cancel(); setError(crmInboxFriendlyError(err, 'Não foi possível conectar o WhatsApp. Tente novamente ou entre em contato com o suporte.')); } finally { setChannelBusy(''); } }; const verifyWhatsApp = async () => { setChannelBusy('verify'); setError(''); setNotice(''); try { const result = await window.API.crm.verifyChannel(); setPayload((current) => ({ ...(current || {}), channels: { ...((current && current.channels) || {}), whatsapp: result.connection }, })); setNotice('Conexão verificada com sucesso.'); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível verificar a conexão. Tente novamente.')); } finally { setChannelBusy(''); } }; const disconnectWhatsApp = async () => { const question = whatsappCoexistence ? 'Desconectar este número do CRM? Ele continuará disponível no WhatsApp Business do celular.' : 'Desconectar este número do CRM?'; if (!window.confirm(question)) return; setChannelBusy('disconnect'); setError(''); try { const result = await window.API.crm.disconnectChannel(); setPayload((current) => ({ ...(current || {}), channels: { ...((current && current.channels) || {}), whatsapp: result.connection }, })); setSelectedId(0); setSelectedConversation(null); setMessages([]); setNotice('Canal desconectado do CRM.'); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível desconectar o WhatsApp. Tente novamente.')); } finally { setChannelBusy(''); } }; const updateAccess = async (targetUserId, role) => { setBusyUserId(targetUserId); setSavedUserId(0); setError(''); try { const result = await window.API.crm.setUserAccess(targetUserId, role); const nextUsers = result.users || []; setPayload((current) => ({ ...(current || {}), users: nextUsers, assignees: nextUsers .filter((item) => item.crmEnabled && item.status === 'ativo') .map((item) => ({ id: item.id, name: item.name || item.login, role: item.crmRole, })), })); setSavedUserId(targetUserId); window.setTimeout(() => setSavedUserId((current) => current === targetUserId ? 0 : current), 1800); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível alterar o acesso.')); } finally { setBusyUserId(0); } }; if (loading) { return (
Gerencie o canal oficial e quem pode atender conversas.
Gerencie o número usado para receber e responder conversas.