/* 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 (
Carregando caixa de entrada…
); } if (error && !payload) { return (
Tentar novamente
); } const currentConversation = selectedConversation || conversations.find((item) => item.id === selectedId) || null; const currentClient = currentConversation && currentConversation.client; const currentContactName = crmInboxContactName(currentConversation); const selectedChannelReady = sendChannel === 'web' ? whatsappWebReady : !!(currentConversation && currentConversation.canSendFreeform); const roleLabel = crmInboxRoleLabels[access.role] || 'Atendente'; const moduleStatusLabel = { trialing: 'Período de teste', active: 'Ativo', grace: 'Período de carência', }[crm.status] || 'Ativo'; const filterItems = [ ['all', 'Todas', counts.all], ['awaiting', 'Aguardando resposta', counts.awaiting], ['open', 'Em atendimento', counts.open], ['unread', 'Não lidas', counts.unread], ['pending', 'Pendentes', counts.pending], ['resolved', 'Resolvidas', counts.resolved], ]; return (
{(error || notice) &&
{error &&
{error}
} {notice &&
{notice}
}
} {settingsView && (
Administração

Configurações do CRM

Gerencie o canal oficial e quem pode atender conversas.

{ setSettingsView(false); setSettingsOpen(false); window.dispatchEvent(new CustomEvent('crm:section-changed', { detail: { target: 'inbox' } })); }}>Voltar à caixa de entrada
)} {!settingsView &&
{whatsappConnected ? 'WhatsApp oficial conectado' : 'WhatsApp oficial não conectado'} {whatsappConnected ? `${whatsapp.displayName || 'Conta verificada'} · ${whatsapp.displayPhoneNumber || 'Número conectado'}` : 'Conecte um número para receber e responder conversas no CRM.'}
{whatsappCoexistence && Celular + CRM} {moduleStatusLabel} · {roleLabel}
{whatsappConnected && ( { loadConversations(); if (selectedId) loadConversation(selectedId, true); }} disabled={conversationLoading || threadLoading} > Atualizar )}
} {settingsView && settingsOpen && access.manageChannels && (

Canal do WhatsApp

Gerencie o número usado para receber e responder conversas.

{whatsappConnected ? 'Conectado' : 'Não conectado'}
WhatsApp Business {whatsappConnected ? `${whatsapp.displayName || 'Conta verificada'} · ${whatsapp.displayPhoneNumber || 'Número conectado'}` : 'Use o mesmo número no aplicativo e no CRM.'}
{whatsappConnected ? ( <> {channelBusy === 'verify' ? 'Verificando…' : 'Verificar'} {channelBusy === 'disconnect' ? 'Desconectando…' : 'Desconectar'} ) : ( {channelBusy === 'connect' ? 'Abrindo conexão…' : 'Conectar WhatsApp'} )}
{!channelSetupReady && (
A conexão do WhatsApp está temporariamente indisponível. Entre em contato com o suporte.
)} {whatsapp && whatsapp.status === 'error' && (
Não foi possível manter a conexão. Verifique novamente ou entre em contato com o suporte.
)}
)} {settingsView && access.manageUsers && (

Respostas rápidas

Cadastre textos compartilhados para toda a equipe de atendimento.

{quickReplies.length} cadastrada{quickReplies.length === 1 ? '' : 's'}
setQuickReplyTitle(event.target.value)} /> setQuickReplyBody(event.target.value)} /> {quickReplyBusy ? 'Salvando…' : 'Adicionar resposta'}
{quickReplies.length === 0 ? ( Nenhuma resposta rápida cadastrada. ) : quickReplies.map((reply) => (
{reply.title}{reply.body} removeQuickReply(reply)} disabled={quickReplyBusy}>Excluir
))}
)} {settingsView && access.manageUsers && (

Etiquetas do CRM

Organize conversas e prepare segmentações para campanhas e automações.

{crmTags.length} etiqueta{crmTags.length === 1 ? '' : 's'}
setTagColorDraft(event.target.value)} aria-label="Cor da etiqueta" /> setTagNameDraft(event.target.value)} /> Criar etiqueta
{crmTags.map((tag) => ( {tag.name} ))}
)} {!settingsView && workspaceView === 'contacts' && (

Contatos

Clientes da Agenda, contatos exclusivos do CRM e pessoas que já conversaram pelo WhatsApp.

{contactSync.session && contactSync.session.status === 'ready' ? 'Sessão QR conectada' : 'Sessão QR desconectada'}{access.manageUsers && {contactSyncBusy || (contactSync.job && ['queued','processing'].includes(contactSync.job.status)) ? 'Sincronizando…' : 'Sincronizar contatos e fotos'}}{directoryContacts.length}{directoryContacts.length === 50 ? '+' : ''} contato{directoryContacts.length === 1 ? '' : 's'}
A sessão por QR é usada somente para importar nomes e fotos. As mensagens do CRM continuam sendo enviadas exclusivamente pela API oficial.
setDirectoryQuery(event.target.value)} /> {directoryQuery && }
{directoryLoading ? (
Carregando contatos…
) : directoryContacts.length === 0 ? (
{directoryQuery ? 'Nenhum contato encontrado' : 'Nenhum contato cadastrado'}{directoryQuery ? 'Tente buscar por outro nome, telefone ou e-mail.' : 'Os clientes cadastrados na Agenda aparecerão aqui.'}
) : (
{directoryContacts.map((contact) =>
{contact.name}{contact.phone || 'Telefone não informado'}
E-mail{contact.email || 'Não informado'}
{contact.source === 'agenda' ? 'Agenda + CRM' : contact.source === 'crm' ? 'Somente CRM' : 'WhatsApp'}{contact.source === 'whatsapp' && setContactEditor({ key: contact.key, name: contact.name || '', phone: contact.phone || '', email: '', scope: 'crm' })}>Cadastrar}
{contactEditor && contactEditor.key === contact.key &&
setContactEditor({ ...contactEditor, name: event.target.value })} /> setContactEditor({ ...contactEditor, email: event.target.value })} /> setContactEditor({ ...contactEditor, scope: event.target.value })}>
{contactSaving ? 'Salvando…' : 'Salvar contato'} setContactEditor(null)} disabled={contactSaving}>Cancelar
}
)}
)}
)} {!settingsView && workspaceView === 'overview' && (
{/* Ambient Lighting Orbs for Apple Liquid Glass */}

Visão geral do CRM

Indicadores em tempo real

Acompanhe atendimento, volume e produtividade da equipe.

{dashboardLoading && !dashboardData ? (
Carregando indicadores do CRM…
) : (() => { const metrics = (dashboardData && dashboardData.metrics) || {}; const daily = (dashboardData && dashboardData.daily) || []; const agents = (dashboardData && dashboardData.agents) || []; const maxDaily = Math.max(1, ...daily.map((item) => Number(item.inbound_count || 0) + Number(item.outbound_count || 0))); const maxAgentLoad = Math.max(1, ...agents.map((a) => Number(a.conversation_count || 0))); const kpis = [ { id: 'conversations', label: 'Total de conversas', value: metrics.conversations || 0, hint: `${metrics.open_count || 0} abertas · ${metrics.resolved_count || 0} resolvidas`, icon: 'whatsapp', tone: 'blue', }, { id: 'awaiting', label: 'Aguardando equipe', value: metrics.awaiting || 0, hint: Number(metrics.awaiting || 0) > 0 ? 'Respostas pendentes da equipe' : 'Nenhuma conversa pendente', icon: 'clock', tone: 'amber', badge: Number(metrics.awaiting || 0) > 0 ? 'Atenção' : 'Em dia', pulse: Number(metrics.awaiting || 0) > 0, }, { id: 'unread', label: 'Não lidas', value: metrics.unread || 0, hint: 'Mensagens novas para ler', icon: 'bell', tone: 'cyan', }, { id: 'inboundToday', label: 'Recebidas hoje', value: metrics.inboundToday || 0, hint: 'Entradas de clientes no canal', icon: 'arrowRight', tone: 'teal', }, { id: 'outboundToday', label: 'Enviadas hoje', value: metrics.outboundToday || 0, hint: 'Respostas e envios da equipe', icon: 'send', tone: 'indigo', }, { id: 'firstResponse', label: '1ª resposta média', value: metrics.avgFirstResponseMinutes == null ? '—' : `${metrics.avgFirstResponseMinutes} min`, hint: 'Tempo médio de 1º retorno', icon: 'zap', tone: 'emerald', }, ]; return ( <>
{kpis.map((kpi) => (
{kpi.badge && ( {kpi.badge} )}
{kpi.label} {kpi.value} {kpi.hint && {kpi.hint}}
))}

Volume dos últimos 14 dias

Distribuição diária entre mensagens recebidas e enviadas.

Recebidas Enviadas
{daily.length === 0 ? (
Sem mensagens registradas no período de 14 dias.
) : daily.map((item) => { const inCount = Number(item.inbound_count || 0); const outCount = Number(item.outbound_count || 0); const total = inCount + outCount; const inPct = total > 0 ? (inCount / total) * 100 : 0; const outPct = total > 0 ? (outCount / total) * 100 : 0; const trackPct = Math.max(4, (total / maxDaily) * 100); const dateFormatted = String(item.day).slice(5).replace('-', '/'); return (
{inCount > 0 && } {outCount > 0 && }
{total} ({inCount} / {outCount})
); })}

Carga por atendente

Conversas atribuídas e pendências por membro da equipe.

{agents.length === 0 ? (
Nenhuma conversa atribuída a atendentes no momento.
) : agents.map((agent) => { const count = Number(agent.conversation_count || 0); const awaiting = Number(agent.awaiting_count || 0); const loadPct = maxAgentLoad > 0 ? Math.max(6, (count / maxAgentLoad) * 100) : 0; return (
{agent.name} 0 ? 'has-awaiting' : 'clear'}`}> {awaiting > 0 ? `${awaiting} aguardando resposta` : 'Nenhuma resposta pendente'}
{count} conversas
); })}
); })()}
)} {!settingsView && workspaceView === 'deals' && (
Comercial

Funil de vendas

Acompanhe oportunidades da entrada ao fechamento.

{salesData.pipelines.length > 0 &&
loadSales(Number(event.target.value))} disabled={salesLoading || salesBusy}>{salesData.pipelines.map((pipeline) => )}{access.managePipeline && <> setPipelineNameDraft(event.target.value)} />Criar}
}
{salesData.pipelines.length === 0 && !salesLoading ? (

Crie seu primeiro funil

Ele será iniciado com as etapas Novo contato, Qualificação, Proposta e Fechamento.

{access.managePipeline ?
setPipelineNameDraft(event.target.value)} />Criar funil
: Solicite a um gerente a criação do funil.}
) : ( <>
setDealDraft({ ...dealDraft, title: event.target.value })} /> setDealDraft({ ...dealDraft, value: event.target.value })} /> setDealDraft({ ...dealDraft, stageId: event.target.value })}>{salesData.stages.map((stage) => )} Adicionar
{salesData.deals.filter((deal) => deal.status === 'open').length} abertas {salesData.deals.filter((deal) => deal.status === 'won').length} ganhas {new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(salesData.deals.filter((deal) => deal.status === 'open').reduce((sum, deal) => sum + Number(deal.value || 0), 0))} em aberto
{salesLoading ?
Carregando funil…
:
{salesData.stages.map((stage) => { const stageDeals = salesData.deals.filter((deal) => Number(deal.stage_id) === Number(stage.id)); return
{stage.name}{stageDeals.length}
{stageDeals.length === 0 ? Nenhuma oportunidade : stageDeals.map((deal) =>
{deal.title}{deal.status === 'won' ? 'Ganha' : deal.status === 'lost' ? 'Perdida' : 'Aberta'}
{new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(deal.value || 0))} {(deal.client_name || deal.assigned_user_name) && {deal.client_name || deal.assigned_user_name}} updateDeal(deal, { stageId: Number(event.target.value) })} disabled={salesBusy}>{salesData.stages.map((option) => )}
{deal.status !== 'open' && } {access.managePipeline && }
)}
; })}
} )}
)} {!settingsView && workspaceView === 'campaigns' && (
Relacionamento

Campanhas

Prepare públicos segmentados e coloque envios de modelos aprovados em uma fila controlada.

{campaignTemplatesError &&
{campaignTemplatesError}
} {access.manageUsers &&
setCampaignDraft({ ...campaignDraft, name: event.target.value })} /> setCampaignDraft({ ...campaignDraft, templateKey: event.target.value })}>{templates.filter((item) => !(item.parameters || []).length && !item.mediaHeader).map((item) => )} setCampaignDraft({ ...campaignDraft, tagId: event.target.value })}>{crmTags.map((tag) => )} submitGrowth('campaign')} disabled={growthBusy || !campaignDraft.name.trim() || !campaignDraft.templateKey}>Criar rascunho
}
{growthLoading ?
Carregando campanhas…
: growthData.campaigns.length === 0 ?
Nenhuma campanha criada.
: growthData.campaigns.map((item) =>
{item.name}{item.template_name} · {item.status}{item.sent_count || 0}/{item.total_recipients || 0}enviadas{access.manageUsers && ['draft','paused','failed'].includes(item.status) && submitGrowth('queue', item)} disabled={growthBusy}>Colocar na fila}{access.manageUsers && ['draft','completed','cancelled','failed'].includes(item.status) && submitGrowth('campaign_delete', item)} disabled={growthBusy}>Excluir}
)}
)} {!settingsView && workspaceView === 'automations' && (

Automações

Defina o que deve acontecer automaticamente quando uma mensagem chegar.

{access.manageUsers &&
setAutomationDraft({ ...automationDraft, name: event.target.value })} />
Quando setAutomationDraft({ ...automationDraft, triggerType: event.target.value })}>
Se setAutomationDraft({ ...automationDraft, keyword: event.target.value })} />
Então setAutomationDraft({ ...automationDraft, actionType: event.target.value })}>
{['internal_note','send_message'].includes(automationDraft.actionType) && setAutomationDraft({ ...automationDraft, actionValue: event.target.value })} />} {automationDraft.actionType === 'add_tag' && setAutomationDraft({ ...automationDraft, tagId: event.target.value })}>{crmTags.map((tag) => )}} {automationDraft.actionType === 'assign_user' && setAutomationDraft({ ...automationDraft, assignedUserId: event.target.value })}>{assignees.map((person) => )}} {automationDraft.actionType === 'set_status' && setAutomationDraft({ ...automationDraft, status: event.target.value })}>}

A automação será criada desativada para revisão.

submitGrowth('automation')} disabled={growthBusy || !automationDraft.name.trim() || (['internal_note','send_message'].includes(automationDraft.actionType) && !automationDraft.actionValue.trim()) || (automationDraft.actionType === 'add_tag' && !automationDraft.tagId) || (automationDraft.actionType === 'assign_user' && !automationDraft.assignedUserId)}>Criar automação
}
{growthLoading ?
Carregando automações…
: growthData.automations.length === 0 ?
Nenhuma automação criada. Use o construtor acima para definir a primeira regra.
: growthData.automations.map((item) => { const actions=JSON.parse(item.actions_json||'[]'); const conditions=JSON.parse(item.conditions_json||'[]'); const labels={internal_note:'Registrar nota interna',send_message:'Enviar resposta',add_tag:'Adicionar etiqueta',assign_user:'Atribuir atendente',set_status:'Alterar situação'}; return
{item.name}Mensagem recebida{conditions[0] ? ` contendo “${conditions[0].value}”` : ''} → {labels[actions[0] && actions[0].type] || 'Executar ação'} · {item.run_count || 0} execuções{Number(item.active) ? 'Ativa' : 'Desativada'}{access.manageUsers && submitGrowth('automation_toggle', item)} disabled={growthBusy}>{Number(item.active) ? 'Desativar' : 'Ativar'}}{access.manageUsers && submitGrowth('automation_delete', item)} disabled={growthBusy}>Excluir}
; })}
)} {!settingsView && workspaceView === 'flows' && (
Conversação

Fluxos

Defina respostas guiadas disparadas por palavra-chave.

{access.manageUsers &&
setFlowDraft({ ...flowDraft, name: event.target.value })} /> setFlowDraft({ ...flowDraft, triggerKeyword: event.target.value })} /> setFlowDraft({ ...flowDraft, message: event.target.value })} /> submitGrowth('flow')} disabled={growthBusy || !flowDraft.name.trim() || !flowDraft.message.trim()}>Criar desativado
}
{growthLoading ?
Carregando…
: growthData.flows.map((item) =>
{item.name}{item.trigger_keyword ? `Gatilho: ${item.trigger_keyword}` : 'Sem palavra-chave'}{Number(item.active) ? 'Ativo' : 'Desativado'}{access.manageUsers && submitGrowth('flow_toggle', item)} disabled={growthBusy}>{Number(item.active) ? 'Desativar' : 'Ativar'}}{access.manageUsers && submitGrowth('flow_delete', item)} disabled={growthBusy}>Excluir}
)}
)} {!settingsView && workspaceView === 'ai' && (
Assistência

Assistente de IA

Configure credencial própria, instruções e conhecimento usado nas sugestões.

{access.manageUsers && <>
setAiDraft({ ...aiDraft, model: event.target.value })} /> setAiDraft({ ...aiDraft, mode: event.target.value })}> setAiDraft({ ...aiDraft, apiKey: event.target.value })} /> setAiDraft({ ...aiDraft, systemPrompt: event.target.value })} /> submitGrowth('ai_settings')} disabled={growthBusy}>Salvar configuração

Base de conhecimento

Políticas, serviços, horários e respostas oficiais.

setAiDraft({ ...aiDraft, knowledgeTitle: event.target.value })} /> setAiDraft({ ...aiDraft, knowledgeContent: event.target.value })} /> submitGrowth('knowledge')} disabled={growthBusy || !aiDraft.knowledgeTitle.trim() || !aiDraft.knowledgeContent.trim()}>Adicionar conteúdo
}
{(growthData.ai && growthData.ai.knowledge || []).map((item) =>
{item.title}{String(item.content).slice(0,160)}{access.manageUsers && submitGrowth('knowledge_delete', item)} disabled={growthBusy}>Excluir}
)}
)} {!settingsView && workspaceView === 'inbox' && (whatsappConnected ? (
Atendimento

Caixa de entrada

setNewConversationOpen(true)}> Nova conversa
{filterItems.map(([value, label, count]) => ( ))}
{inboxView === 'board' ? (
Quadro de atendimentosArraste os cartões para atualizar a situação
setSearch(event.target.value)} aria-label="Buscar por nome, telefone ou e-mail" />
{access.viewAll && ( setAssignedFilter(event.target.value)} > )}
{[ ['awaiting', 'Aguardando resposta', 'O cliente enviou a última mensagem', 'bell', ''], ['open', 'Em atendimento', 'Conversas sendo atendidas agora', 'whatsapp', 'open'], ['pending', 'Pendentes', 'Aguardando retorno ou ação', 'clock', 'pending'], ['resolved', 'Concluídas', 'Atendimentos finalizados', 'check', 'resolved'], ].map(([key, label, description, icon, dropStatus]) => (
{ if (!dropStatus) return; event.preventDefault(); event.dataTransfer.dropEffect = 'move'; setBoardDropStatus(dropStatus); }} onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) setBoardDropStatus(''); }} onDrop={(event) => { if (!dropStatus) return; event.preventDefault(); const conversationId = Number(event.dataTransfer.getData('text/plain') || boardDragId); if (conversationId) moveBoardConversation(conversationId, dropStatus); }} >
{label}{description} {visibleBoardColumns[key].length}
{conversationLoading && conversations.length === 0 ? (
Carregando atendimentos…
) : visibleBoardColumns[key].length === 0 ? (
Nenhum atendimento nesta etapa
) : visibleBoardColumns[key].map((conversation) => ( selectBoardConversation(key, conversationId, checked)} onOpen={openBoardConversation} onMove={moveBoardConversation} onDragStart={(event, item) => { setBoardDragId(Number(item.id)); event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', String(item.id)); }} onDragEnd={() => { setBoardDragId(0); setBoardDropStatus(''); }} /> ))}
))}
) : (
{!currentConversation ? (
Selecione uma conversa Abra um atendimento para ver o histórico e responder.
) : ( <>
{!currentClient && } {currentConversation.status !== 'pending' && ( )} {currentConversation.status !== 'resolved' && ( )} {currentConversation.status === 'resolved' && ( )}
{threadLoading && messages.length === 0 ? (
Carregando histórico…
) : messages.length === 0 ? (
Nenhuma mensagem nesta conversa.
) : arrangedMessages.map((item) => ( { setReplyingTo(message); window.setTimeout(() => document.querySelector('.crm-inbox-composer .textarea')?.focus(), 0); }} key={item.message.id} /> ))}
{sendChannel === 'official' && !currentConversation.canSendFreeform && (
A janela de 24 horas está fechada. Use Nova conversa para enviar um modelo aprovado e aguarde a resposta do cliente.
)} {sendChannel === 'web' && !whatsappWebReady && (
A sessão WhatsApp Web está indisponível. Reconecte-a nas configurações ou selecione o canal oficial.
)}
{sendChannel === 'web' ? 'Saída pela sessão vinculada · recebimento continua oficial' : 'Saída e recebimento pela API oficial'}
{user.name || user.login} conectado · cliente verá {senderDisplayName} em negrito
{senderEditorOpen && (
setSenderNameDraft(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); saveSenderIdentity(); } }} /> {senderIdentityBusy ? 'Salvando…' : 'Salvar nome'}
)} {quickReplies.length > 0 &&
{quickReplies.map((reply) => ( ))}
} {attachment && (
{attachment.name}{(attachment.size / 1024 / 1024).toFixed(1)} MB
)} {replyingTo && (
Respondendo à mensagem {crmInboxReplyPreview(replyingTo)}
)} setMessageBody(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); if (!messageBusy && messageBody.trim()) event.currentTarget.form.requestSubmit(); } }} />
Enter envia · Shift + Enter quebra a linha
setAttachment(event.target.files && event.target.files[0] ? event.target.files[0] : null)} /> {messageBody.length}/{messageMaxLength} {messageBusy ? 'Enviando…' : 'Enviar'}
)}
)}
) : (
{access.manageChannels && (
{channelBusy === 'connect' ? 'Abrindo conexão…' : 'Conectar WhatsApp'}
)}
))} {settingsView && access.manageUsers && (
{accessOpen && (
{users.map((item) => (
{item.name || item.login} {item.login} · {item.agendaRole === 'admin' ? 'Administrador da Agenda' : 'Usuário da Agenda'}
updateAccess(item.id, event.target.value)} > {item.isPrimaryAdmin && Acesso obrigatório} {busyUserId === item.id && Salvando…} {savedUserId === item.id && Salvo}
))}
)}
)} { setTemplateKey(value); setTemplateParameters({}); setTemplateMedia(null); }} templateParameters={templateParameters} setTemplateParameters={setTemplateParameters} templateMedia={templateMedia} setTemplateMedia={setTemplateMedia} error={newConversationError} busy={newBusy} onClose={() => { if (newBusy) return; setNewConversationOpen(false); setContactQuery(''); setContacts([]); setNewPhone(''); setTemplateKey(''); setTemplateParameters({}); setTemplateMedia(null); setNewConversationError(''); }} onSubmit={sendNewConversation} />
); } // A empresa de demonstração mantém a tela usada na análise da Meta. // Os demais tenants recebem a caixa de entrada operacional atual. function CrmInboxRouteScreen(props) { return props.user && props.user.hasCrm ? : ; } if (window.TENANT_SLUG !== 'empresa-demonstracao') { window.CrmScreen = CrmInboxRouteScreen; }