/* 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 [workspaceView, setWorkspaceView] = React.useState('inbox'); const [dashboardData, setDashboardData] = React.useState(null); const [dashboardLoading, setDashboardLoading] = React.useState(false); const [salesData, setSalesData] = React.useState({ pipelines: [], pipelineId: 0, stages: [], deals: [] }); const [salesLoading, setSalesLoading] = React.useState(false); const [salesBusy, setSalesBusy] = React.useState(false); const [pipelineNameDraft, setPipelineNameDraft] = React.useState(''); const [dealDraft, setDealDraft] = React.useState({ title: '', value: '', stageId: '' }); const [growthData, setGrowthData] = React.useState({ campaigns: [], automations: [], flows: [], ai: null }); const [growthLoading, setGrowthLoading] = React.useState(false); const [growthBusy, setGrowthBusy] = React.useState(false); const [campaignTemplatesError, setCampaignTemplatesError] = React.useState(''); const [campaignDraft, setCampaignDraft] = React.useState({ name: '', templateKey: '', tagId: '' }); const [automationDraft, setAutomationDraft] = React.useState({ name: '', triggerType: 'message_received', keyword: '', actionType: 'internal_note', actionValue: '', tagId: '', assignedUserId: '', status: 'pending' }); const [flowDraft, setFlowDraft] = React.useState({ name: '', triggerKeyword: '', message: '' }); const [aiDraft, setAiDraft] = React.useState({ model: '', mode: 'off', systemPrompt: '', apiKey: '', knowledgeTitle: '', knowledgeContent: '' }); 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 [sendChannel, setSendChannel] = React.useState(() => window.localStorage.getItem('crmSendChannel') === 'web' ? 'web' : 'official'); const [aiSuggestBusy, setAiSuggestBusy] = 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 [boardBulkBusy, setBoardBulkBusy] = React.useState(false); const [boardDragId, setBoardDragId] = React.useState(0); const [boardDropStatus, setBoardDropStatus] = React.useState(''); const [boardSearches, setBoardSearches] = React.useState({ awaiting: '', open: '', pending: '', resolved: '' }); const [boardSelected, setBoardSelected] = React.useState({ awaiting: [], open: [], pending: [], resolved: [] }); const [boardBulkStatus, setBoardBulkStatus] = React.useState({ awaiting: 'open', open: 'pending', pending: 'resolved', resolved: 'open' }); const [hideOldResolved, setHideOldResolved] = React.useState(() => window.localStorage.getItem('crmHideOldResolved') !== '0'); 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 [directoryQuery, setDirectoryQuery] = React.useState(''); const [directoryContacts, setDirectoryContacts] = React.useState([]); const [directoryLoading, setDirectoryLoading] = React.useState(false); const [contactEditor, setContactEditor] = React.useState(null); const [contactSaving, setContactSaving] = React.useState(false); const [contactSync, setContactSync] = React.useState({ session: null, job: null }); const [contactSyncBusy, setContactSyncBusy] = React.useState(false); const contactSyncCompletionRef = React.useRef(0); 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); const [quickReplies, setQuickReplies] = React.useState([]); const [quickReplyTitle, setQuickReplyTitle] = React.useState(''); const [quickReplyBody, setQuickReplyBody] = React.useState(''); const [quickReplyBusy, setQuickReplyBusy] = React.useState(false); const [conversationContext, setConversationContext] = React.useState({ tags: [], selectedTagIds: [], notes: [] }); const [contextBusy, setContextBusy] = React.useState(false); const [noteDraft, setNoteDraft] = React.useState(''); const [tagNameDraft, setTagNameDraft] = React.useState(''); const [tagColorDraft, setTagColorDraft] = React.useState('#6366f1'); const [crmTags, setCrmTags] = React.useState([]); React.useEffect(() => { const navigate = (event) => { const target = String((event.detail && event.detail.target) || 'inbox'); setError(''); setNotice(''); 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 === 'overview') { setWorkspaceView('overview'); return; } if (target === 'deals') { setWorkspaceView('deals'); return; } if (['campaigns', 'automations', 'flows', 'ai'].includes(target)) { setWorkspaceView(target); return; } if (target === 'contacts') { setWorkspaceView('contacts'); return; } setWorkspaceView('inbox'); 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 whatsappWeb = payload && payload.channels && payload.channels.whatsappWeb; const whatsappWebReady = !!(whatsappWeb && whatsappWeb.ready); 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.status === 'resolved') columns.resolved.push(conversation); else if (conversation.status === 'pending') columns.pending.push(conversation); else if (conversation.lastMessageDirection === 'inbound') columns.awaiting.push(conversation); else columns.open.push(conversation); }); return columns; }, [conversations]); const visibleBoardColumns = React.useMemo(() => { const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000); return Object.keys(boardColumns).reduce((result, key) => { const query = String(boardSearches[key] || '').trim().toLocaleLowerCase('pt-BR'); result[key] = boardColumns[key].filter((conversation) => { if (key === 'resolved' && hideOldResolved) { const resolvedTime = Date.parse(conversation.resolvedAt || conversation.updatedAt || ''); if (Number.isFinite(resolvedTime) && resolvedTime < sevenDaysAgo) return false; } if (!query) return true; const searchable = [ conversation.displayName, conversation.counterpartName, conversation.counterpartPhone, conversation.lastMessagePreview, conversation.client && conversation.client.email, conversation.crmContact && conversation.crmContact.email, conversation.assignedUser && conversation.assignedUser.name, ].filter(Boolean).join(' ').toLocaleLowerCase('pt-BR'); return searchable.includes(query); }); return result; }, {}); }, [boardColumns, boardSearches, hideOldResolved]); React.useEffect(() => { setBoardSelected((current) => Object.keys(current).reduce((result, key) => { const visibleIds = new Set((visibleBoardColumns[key] || []).map((item) => Number(item.id))); result[key] = current[key].filter((id) => visibleIds.has(Number(id))); return result; }, {})); }, [visibleBoardColumns]); 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 loadDashboard = React.useCallback(async () => { setDashboardLoading(true); try { setDashboardData(await window.API.crm.dashboard()); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível carregar os indicadores do CRM.')); } finally { setDashboardLoading(false); } }, []); React.useEffect(() => { if (workspaceView === 'overview') loadDashboard(); }, [workspaceView, loadDashboard]); const loadSales = React.useCallback(async (pipelineId = 0) => { setSalesLoading(true); try { const result = await window.API.crm.sales(pipelineId); setSalesData({ pipelines: result.pipelines || [], pipelineId: Number(result.pipelineId || 0), stages: result.stages || [], deals: result.deals || [], }); setDealDraft((current) => { const stages = result.stages || []; const validCurrent = stages.some((stage) => Number(stage.id) === Number(current.stageId)); return { ...current, stageId: String(validCurrent ? current.stageId : (stages[0] ? stages[0].id : '')) }; }); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível carregar o funil de vendas.')); } finally { setSalesLoading(false); } }, []); React.useEffect(() => { if (workspaceView === 'deals') loadSales(); }, [workspaceView, loadSales]); const createPipeline = async () => { if (!pipelineNameDraft.trim() || salesBusy) return; setSalesBusy(true); try { const result = await window.API.crm.createPipeline(pipelineNameDraft.trim()); setPipelineNameDraft(''); await loadSales(Number(result.pipelineId || 0)); setNotice('Funil criado com as etapas iniciais.'); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível criar o funil.')); } finally { setSalesBusy(false); } }; const createDeal = async () => { if (!dealDraft.title.trim() || !salesData.pipelineId || !dealDraft.stageId || salesBusy) return; setSalesBusy(true); try { await window.API.crm.createDeal({ pipelineId: salesData.pipelineId, stageId: Number(dealDraft.stageId), title: dealDraft.title.trim(), value: Number(String(dealDraft.value || '0').replace(',', '.')) || 0 }); setDealDraft((current) => ({ ...current, title: '', value: '' })); await loadSales(salesData.pipelineId); setNotice('Oportunidade adicionada ao funil.'); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível criar a oportunidade.')); } finally { setSalesBusy(false); } }; const updateDeal = async (deal, changes) => { if (salesBusy) return; setSalesBusy(true); try { await window.API.crm.updateDeal(deal.id, changes); await loadSales(salesData.pipelineId); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível atualizar a oportunidade.')); } finally { setSalesBusy(false); } }; const removeDeal = async (deal) => { if (!deal || salesBusy || !window.confirm(`Excluir a oportunidade “${deal.title}”?`)) return; setSalesBusy(true); try { await window.API.crm.removeDeal(deal.id); await loadSales(salesData.pipelineId); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível excluir a oportunidade.')); } finally { setSalesBusy(false); } }; const loadGrowthView = React.useCallback(async (view) => { setGrowthLoading(true); setError(''); try { if (view === 'campaigns') { const campaignsResult = await window.API.crm.campaigns(); setGrowthData((current) => ({ ...current, campaigns: campaignsResult.campaigns || [] })); setCampaignTemplatesError(''); try { const templateResult = await window.API.crm.messageTemplates(); setTemplates(templateResult.templates || []); } catch (templateError) { setTemplates([]); setCampaignTemplatesError(crmInboxFriendlyError(templateError, 'Os modelos aprovados do WhatsApp estão indisponíveis. Revise a configuração segura do canal antes de criar uma campanha.')); } } else if (view === 'automations') { const result = await window.API.crm.automations(); setGrowthData((current) => ({ ...current, automations: result.automations || [] })); } else if (view === 'flows') { const result = await window.API.crm.flows(); setGrowthData((current) => ({ ...current, flows: result.flows || [] })); } else if (view === 'ai') { const result = await window.API.crm.ai(); setGrowthData((current) => ({ ...current, ai: result })); setAiDraft((current) => ({ ...current, model: result.settings.model || '', mode: result.settings.mode || 'off', systemPrompt: result.settings.system_prompt || '', apiKey: '' })); } } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível carregar este módulo.')); } finally { setGrowthLoading(false); } }, []); React.useEffect(() => { if (['campaigns', 'automations', 'flows', 'ai'].includes(workspaceView)) loadGrowthView(workspaceView); }, [workspaceView, loadGrowthView]); const submitGrowth = async (kind, row = null) => { if (growthBusy) return; if (kind.endsWith('_delete') && (!row || !window.confirm(`Excluir “${row.name || row.title}”?`))) return; setGrowthBusy(true); try { if (kind === 'campaign') { const selected = templates.find((item) => `${item.name}|${item.language}` === campaignDraft.templateKey); if (!campaignDraft.name.trim() || !selected) throw new Error('Informe o nome e selecione um modelo aprovado.'); await window.API.crm.createCampaign({ name: campaignDraft.name.trim(), templateName: selected.name, language: selected.language, tagId: Number(campaignDraft.tagId || 0) }); setCampaignDraft({ name: '', templateKey: '', tagId: '' }); } else if (kind === 'queue') await window.API.crm.queueCampaign(row.id); else if (kind === 'campaign_delete') await window.API.crm.removeCampaign(row.id); else if (kind === 'automation') { const action = automationDraft.actionType === 'add_tag' ? { type: 'add_tag', tagId: Number(automationDraft.tagId) } : automationDraft.actionType === 'assign_user' ? { type: 'assign_user', userId: Number(automationDraft.assignedUserId) } : automationDraft.actionType === 'set_status' ? { type: 'set_status', status: automationDraft.status } : { type: automationDraft.actionType, body: automationDraft.actionValue.trim() }; await window.API.crm.createAutomation({ name: automationDraft.name.trim(), triggerType: automationDraft.triggerType, conditions: automationDraft.keyword.trim() ? [{ type: 'message_contains', value: automationDraft.keyword.trim() }] : [], actions: [action], active: false }); setAutomationDraft({ name: '', triggerType: 'message_received', keyword: '', actionType: 'internal_note', actionValue: '', tagId: '', assignedUserId: '', status: 'pending' }); } else if (kind === 'automation_toggle') await window.API.crm.updateAutomation(row.id, { active: !Number(row.active) }); else if (kind === 'automation_delete') await window.API.crm.removeAutomation(row.id); else if (kind === 'flow') { await window.API.crm.createFlow({ name: flowDraft.name.trim(), triggerKeyword: flowDraft.triggerKeyword.trim(), nodes: [{ type: 'message', text: flowDraft.message.trim() }], active: false }); setFlowDraft({ name: '', triggerKeyword: '', message: '' }); } else if (kind === 'flow_toggle') await window.API.crm.updateFlow(row.id, { active: !Number(row.active) }); else if (kind === 'flow_delete') await window.API.crm.removeFlow(row.id); else if (kind === 'ai_settings') await window.API.crm.saveAiSettings({ model: aiDraft.model.trim(), mode: aiDraft.mode, systemPrompt: aiDraft.systemPrompt.trim(), apiKey: aiDraft.apiKey.trim() }); else if (kind === 'knowledge') { await window.API.crm.createAiKnowledge({ title: aiDraft.knowledgeTitle.trim(), content: aiDraft.knowledgeContent.trim() }); setAiDraft((current) => ({ ...current, knowledgeTitle: '', knowledgeContent: '' })); } else if (kind === 'knowledge_delete') await window.API.crm.removeAiKnowledge(row.id); await loadGrowthView(workspaceView); setNotice('Alteração salva.'); } catch (err) { setError(crmInboxFriendlyError(err, err.message || 'Não foi possível salvar.')); } finally { setGrowthBusy(false); } }; const saveDirectoryContact = async () => { if (!contactEditor || contactSaving) return; setContactSaving(true); setError(''); try { await window.API.crm.saveContact(contactEditor); setNotice(contactEditor.scope === 'agenda' ? 'Cliente incluído na Agenda e no CRM.' : 'Contato incluído somente no CRM.'); setContactEditor(null); const result = await window.API.crm.contacts(directoryQuery, 50); setDirectoryContacts(result.contacts || []); await loadConversations(); if (selectedId) await loadConversation(selectedId, false, true); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível salvar o contato.')); } finally { setContactSaving(false); } }; const beginConversationContact = () => { if (!currentConversation) return; setContactEditor({ key: `conversation:${currentConversation.id}`, name: (currentConversation.crmContact && currentConversation.crmContact.name) || currentContactName || '', phone: currentConversation.counterpartPhone || '', email: (currentConversation.crmContact && currentConversation.crmContact.email) || '', scope: currentConversation.crmContact ? 'agenda' : 'crm', }); setContextOpen(true); }; const loadQuickReplies = React.useCallback(async () => { try { const result = await window.API.crm.quickReplies(); setQuickReplies(result.quickReplies || []); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível carregar as respostas rápidas.')); } }, []); React.useEffect(() => { loadQuickReplies(); }, [loadQuickReplies]); const loadCrmTags = React.useCallback(async () => { try { const result = await window.API.crm.tags(); setCrmTags(result.tags || []); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível carregar as etiquetas.')); } }, []); React.useEffect(() => { loadCrmTags(); }, [loadCrmTags]); React.useEffect(() => { if (workspaceView !== 'contacts') return undefined; let active = true; const timer = window.setTimeout(async () => { setDirectoryLoading(true); setError(''); try { const result = await window.API.crm.contacts(directoryQuery, 50); if (active) setDirectoryContacts(result.contacts || []); } catch (err) { if (active) setError(crmInboxFriendlyError(err, 'Não foi possível carregar os contatos.')); } finally { if (active) setDirectoryLoading(false); } }, directoryQuery.trim() ? 220 : 0); return () => { active = false; window.clearTimeout(timer); }; }, [workspaceView, directoryQuery]); React.useEffect(() => { if (workspaceView !== 'contacts') return undefined; let active=true; const refresh=async()=>{ try { const result=await window.API.crm.contactSyncStatus(); if (!active) return; setContactSync(result); const job=result.job; if (job && job.status==='completed' && contactSyncCompletionRef.current!==Number(job.id)) { contactSyncCompletionRef.current=Number(job.id); const contactsResult=await window.API.crm.contacts(directoryQuery,50); if (active) { setDirectoryContacts(contactsResult.contacts||[]); setNotice(`${job.imported_count || 0} contatos sincronizados; ${job.photo_count || 0} fotos encontradas.`); } } } catch (_) {} }; refresh(); const timer=window.setInterval(refresh,3000); return()=>{active=false;window.clearInterval(timer);}; }, [workspaceView, directoryQuery]); const startContactSync = async () => { if (contactSyncBusy) return; setContactSyncBusy(true); setError(''); try { await window.API.crm.syncContacts(); setNotice('Sincronização solicitada. A sessão por QR fará somente a leitura dos contatos.'); setContactSync((current)=>({...current,job:{status:'queued'}})); } catch (err) { setError(crmInboxFriendlyError(err,'Não foi possível iniciar a sincronização.')); } finally { setContactSyncBusy(false); } }; React.useEffect(() => { if (!notice) return undefined; const timer = window.setTimeout(() => setNotice(''), 3600); return () => window.clearTimeout(timer); }, [notice]); const loadConversationContext = React.useCallback(async (conversationId) => { if (!conversationId) { setConversationContext({ tags: [], selectedTagIds: [], notes: [] }); return; } try { const result = await window.API.crm.conversationContext(conversationId); setConversationContext({ tags: result.tags || [], selectedTagIds: (result.selectedTagIds || []).map(Number), notes: result.notes || [], }); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível carregar etiquetas e notas.')); } }, []); React.useEffect(() => { loadConversationContext(selectedId); }, [selectedId, loadConversationContext]); const toggleConversationTag = async (tagId) => { if (!selectedId || contextBusy) return; const selected = conversationContext.selectedTagIds.includes(Number(tagId)); setContextBusy(true); try { if (selected) await window.API.crm.removeConversationTag(selectedId, tagId); else await window.API.crm.addConversationTag(selectedId, tagId); await loadConversationContext(selectedId); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível atualizar a etiqueta.')); } finally { setContextBusy(false); } }; const createConversationNote = async () => { if (!selectedId || !noteDraft.trim() || contextBusy) return; setContextBusy(true); try { await window.API.crm.createNote(selectedId, { body: noteDraft.trim() }); setNoteDraft(''); await loadConversationContext(selectedId); setNotice('Nota interna adicionada.'); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível adicionar a nota.')); } finally { setContextBusy(false); } }; const deleteConversationNote = async (note) => { if (!note || contextBusy || !window.confirm('Excluir esta nota interna?')) return; setContextBusy(true); try { await window.API.crm.removeNote(note.id); await loadConversationContext(selectedId); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível excluir a nota.')); } finally { setContextBusy(false); } }; const createCrmTag = async () => { if (!tagNameDraft.trim() || contextBusy) return; setContextBusy(true); try { await window.API.crm.createTag({ name: tagNameDraft.trim(), color: tagColorDraft }); setTagNameDraft(''); await Promise.all([loadCrmTags(), loadConversationContext(selectedId)]); setNotice('Etiqueta criada.'); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível criar a etiqueta.')); } finally { setContextBusy(false); } }; const deleteCrmTag = async (tag) => { if (!tag || contextBusy || !window.confirm(`Excluir a etiqueta “${tag.name}”?`)) return; setContextBusy(true); try { await window.API.crm.removeTag(tag.id); await Promise.all([loadCrmTags(), loadConversationContext(selectedId)]); setNotice('Etiqueta excluída.'); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível excluir a etiqueta.')); } finally { setContextBusy(false); } }; const createQuickReply = async () => { if (!quickReplyTitle.trim() || !quickReplyBody.trim() || quickReplyBusy) return; setQuickReplyBusy(true); setError(''); try { await window.API.crm.createQuickReply({ title: quickReplyTitle.trim(), body: quickReplyBody.trim() }); setQuickReplyTitle(''); setQuickReplyBody(''); await loadQuickReplies(); setNotice('Resposta rápida criada.'); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível criar a resposta rápida.')); } finally { setQuickReplyBusy(false); } }; const removeQuickReply = async (reply) => { if (!reply || quickReplyBusy || !window.confirm(`Excluir a resposta rápida “${reply.title}”?`)) return; setQuickReplyBusy(true); setError(''); try { await window.API.crm.removeQuickReply(reply.id); await loadQuickReplies(); setNotice('Resposta rápida excluída.'); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível excluir a resposta rápida.')); } finally { setQuickReplyBusy(false); } }; 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) setNewConversationError(crmInboxFriendlyError(err, 'Os modelos aprovados do WhatsApp estão indisponíveis. Revise a configuração do canal e tente novamente.')); }) .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 toggleOldResolved = () => { setHideOldResolved((current) => { const next = !current; window.localStorage.setItem('crmHideOldResolved', next ? '1' : '0'); return next; }); }; const selectBoardConversation = (column, conversationId, checked) => { setBoardSelected((current) => ({ ...current, [column]: checked ? Array.from(new Set([...current[column], Number(conversationId)])) : current[column].filter((id) => Number(id) !== Number(conversationId)), })); }; const selectAllBoardColumn = (column, checked) => { setBoardSelected((current) => ({ ...current, [column]: checked ? visibleBoardColumns[column].map((item) => Number(item.id)) : [], })); }; const bulkMoveBoardColumn = async (column) => { const ids = boardSelected[column] || []; const status = boardBulkStatus[column]; if (!ids.length || boardBulkBusy || !['open', 'pending', 'resolved'].includes(status)) return; setBoardBulkBusy(true); setError(''); try { const result = await window.API.crm.updateConversations(ids, status); setBoardSelected((current) => ({ ...current, [column]: [] })); setNotice(`${result.updated || ids.length} atendimento(s) atualizado(s).`); await loadConversations(true); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível atualizar os atendimentos selecionados.')); } finally { setBoardBulkBusy(false); } }; 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; if (sendChannel === 'web' && attachment) { setError('Para enviar anexos, selecione o canal oficial.'); 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 : '', sendChannel ); 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 suggestAiReply = async () => { if (!selectedId || aiSuggestBusy) return; setAiSuggestBusy(true); setError(''); try { const result = await window.API.crm.aiDraft(selectedId); setMessageBody(result.draft || ''); setNotice('Rascunho criado pela IA. Revise antes de enviar.'); } catch (err) { setError(crmInboxFriendlyError(err, 'Não foi possível gerar a sugestão.')); } finally { setAiSuggestBusy(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.
Cadastre textos compartilhados para toda a equipe de atendimento.
Organize conversas e prepare segmentações para campanhas e automações.
Clientes da Agenda, contatos exclusivos do CRM e pessoas que já conversaram pelo WhatsApp.
Acompanhe atendimento, volume e produtividade da equipe.
Distribuição diária entre mensagens recebidas e enviadas.
Conversas atribuídas e pendências por membro da equipe.
Acompanhe oportunidades da entrada ao fechamento.
Ele será iniciado com as etapas Novo contato, Qualificação, Proposta e Fechamento.
Prepare públicos segmentados e coloque envios de modelos aprovados em uma fila controlada.
Defina o que deve acontecer automaticamente quando uma mensagem chegar.
A automação será criada desativada para revisão.
Defina respostas guiadas disparadas por palavra-chave.
Configure credencial própria, instruções e conhecimento usado nas sugestões.
Políticas, serviços, horários e respostas oficiais.