/* Painel Nanda — camada de dados. window.App.Data DEMO_MODE (sem anon key) -> mocks EM MEMÓRIA no formato do banco. Real -> Supabase via ANON key (RLS) para leitura + backend (Railway, com a sessão do painel no Bearer) para status/ações. Nenhum segredo aqui: service_role e credenciais da Zernio vivem só no backend. */ (function () { const CFG = window.NANDA_CONFIG || {}; const DEMO = CFG.DEMO_MODE !== false ? !CFG.SUPABASE_ANON_KEY : false; /* ── Enum de status do pipeline (mapeia leads.status -> colunas do Kanban) ── */ const COLUMNS = [ { key: 'novo', label: 'NOVOS LEADS' }, { key: 'qualificacao', label: 'EM QUALIFICAÇÃO' }, { key: 'oportunidade', label: 'OPORTUNIDADE' }, { key: 'fechamento', label: 'FECHAMENTO' }, { key: 'ganho', label: 'GANHO' }, { key: 'perdido', label: 'PERDIDO' }, ]; const STATUS_LABEL = { novo: 'Novo Lead', qualificacao: 'Em Qualificação', oportunidade: 'Oportunidade', fechamento: 'Em Fechamento', ganho: 'Cliente', perdido: 'Perdido', }; const PRIO_LABEL = { alta: 'Alta', media: 'Média', baixa: 'Baixa' }; /* ── Mocks: leads (rádio). Tudo FICTÍCIO: nomes inventados, telefones com DDD 00 (não existe no Brasil), e-mails em domínio .test; sem PII real. As conversas não afirmam preço, programação nem promoção da rádio real (é só demonstração). ── */ const now = Date.now(); const h = (n) => new Date(now - n * 3600e3).toISOString(); const d = (n) => new Date(now - n * 86400e3).toISOString(); const LEADS = [ mkLead('l01', 'Fernanda Lima', '5500900000101', 'qualificacao', 'alta', ['anunciar a loja', 'spot de 30s', 'horário da manhã'], 'enviar tabela comercial', 2, 4, 'Comercial'), mkLead('l02', 'Carlos Mendes', '5500900000102', 'novo', 'media', ['dúvida sobre valores', 'pacote mensal'], 'enviar proposta', 1, 21, null), mkLead('l03', 'Renata Souza', '5500900000103', 'oportunidade', 'alta', ['patrocínio de quadro', 'evento em novembro'], 'confirmar cota', 20, 6, 'Comercial'), mkLead('l04', 'Rafael Andrade', '5500900000104', 'novo', 'baixa', ['pediu endereço da rádio'], 'followup', 5, 30, null), mkLead('l05', 'Juliana Castro', '5500900000105', 'qualificacao', 'media', ['campanha institucional', 'nota fiscal para empresa'], 'oferecer datas', 3, 9, 'Produção'), mkLead('l06', 'Tiago Tavares', '5500900000106', 'fechamento', 'alta', ['pacote de 3 meses', 'pagamento mensal'], 'registrar contrato', 8, 3, 'Comercial'), mkLead('l07', 'Aline Ribeiro', '5500900000107', 'ganho', 'media', ['campanha confirmada 26/08'], 'mensagem na véspera da estreia', 40, 12, 'Produção'), mkLead('l08', 'Marcelo Dias', '5500900000108', 'perdido', 'baixa', ['achou caro', 'sem retorno há 30 dias'], 'reativar em 30 dias', 96, 34, null), mkLead('l09', 'Tatiane Nunes', '5500900000109', 'novo', 'media', ['perguntou sobre pedido de música', 'ouvinte'], 'qualificar necessidade', 6, 18, null), mkLead('l10', 'Lúcio Siqueira', '5500900000110', 'novo', 'baixa', ['só pesquisando'], 'nutrir', 21, 25, null), mkLead('l11', 'Veronica Menezes', '5500900000111', 'qualificacao', 'alta', ['cobertura de inauguração', 'ação externa'], 'montar proposta de evento', 4, 7, 'Produção'), mkLead('l12', 'Pedro Milagre', '5500900000112', 'oportunidade', 'media', ['entrevista no programa', 'agenda da próxima semana'], 'confirmar horário da gravação', 22, 5, 'Produção'), mkLead('l13', 'Sandra Oliveira', '5500900000113', 'novo', 'media', ['promoção para ouvintes'], 'enviar regulamento', 9, 15, null), mkLead('l14', 'Diego Fontes', '5500900000114', 'ganho', 'alta', ['anunciante recorrente'], 'follow-up mensal', 60, 48, 'Comercial'), mkLead('l15', 'Camila Ávila', '5500900000115', 'fechamento', 'media', ['aguardando aprovação da peça'], 'confirmar', 12, 4, 'Produção'), ]; function mkLead(id, nome, tel, status, prio, interesses, proxima, ultHrs, criadoDias, resp) { return { id, nome, telefone: tel, status, prioridade: prio, canal: 'whatsapp', valor: 0, responsavel: resp, criado_em: d(criadoDias), ultima_interacao: h(ultHrs), total_conversas: Math.max(1, Math.round((criadoDias - ultHrs / 24) / 2) + 3), memoria: { interesses, proxima_acao: proxima }, }; } /* ── Mocks: sessions (conversa vive em data.historico, JSONB) ── */ const HIST = { l01: [ m(false, 'Oi, vi o Instagram de vocês. Quanto custa anunciar a minha loja na rádio?', 6.4), m(true, 'Oi, Fernanda! Que bom falar com você. Vou conferir com a equipe comercial as opções. Seria um spot gravado ou uma chamada ao vivo?', 6.3), m(false, 'Spot gravado, de uns 30 segundos. De preferência de manhã', 6.2), m(true, 'Anotei: spot de 30s, horário da manhã. Quer que eu já te passe a tabela assim que a equipe confirmar?', 6.1), m(false, 'Quero sim', 5.9), m(true, 'Combinado! Passei pra equipe comercial e te retorno com as opções e o valor, pode ser?', 5.8), ], l03: [ m(false, 'Bom dia, queria saber se dá pra patrocinar um quadro em novembro', 8), m(true, 'Bom dia, Renata! Vou verificar pra você. Qual seria o segmento da empresa e a ideia do quadro?', 7.8), m(false, 'Loja de móveis planejados. Pensei em dicas rápidas de decoração', 7.6), m(true, 'Anotado. Vou pedir pra equipe confirmar a disponibilidade da cota e já te retorno com o valor.', 6), ], l06: [ m(false, 'Quero fechar os 3 meses que conversamos', 3.4), m(true, 'Ótimo, Tiago! Vou registrar seu pedido. O pagamento mensal funciona pra você?', 3.2), m(false, 'Funciona sim', 3.1), m(true, 'Perfeito! A equipe te manda o contrato por aqui mesmo.', 3.0), ], l02: [m(false, 'Quanto custa um anúncio?', 21), m(true, 'Oi, Carlos! Depende do formato e do horário. Me conta o que você quer divulgar e por quanto tempo que eu te passo as opções certinhas.', 20.8)], l11: [ m(false, 'Vocês fazem cobertura de evento? Vamos inaugurar uma loja nova', 7.3), m(true, 'Oi, Veronica! Pra ações externas a equipe monta uma proposta com os formatos disponíveis. Qual seria a data?', 7.1), m(false, 'Primeiro fim de semana de dezembro', 7.0), m(true, 'Anotado. Vou pedir pra equipe montar a proposta do evento e te retorno, pode ser?', 6.9), ], l12: [m(false, 'Posso gravar a entrevista na semana que vem?', 5.2), m(true, 'Oi, Pedro! Me passa os dias e horários que você tem livres que eu combino a gravação com a produção.', 5.0)], }; function m(fromMe, text, hoursAgo) { return { fromMe, text, type: 'text', timestamp: new Date(now - hoursAgo * 3600e3).toISOString() }; } const SESSIONS = LEADS.filter((l) => HIST[l.id]).map((l, i) => ({ id: 's' + l.id, lead_id: l.id, telefone: l.telefone, pausado: l.id === 'l03', // 1 exemplo pausado atualizado_em: HIST[l.id][HIST[l.id].length - 1].timestamp, data: { historico: HIST[l.id] }, })); /* ── Mocks: confirmacoes (agenda de reuniões e gravações) ── */ const CONFIRM = [ conf('c1', 'l01', 'Reunião comercial — Fernanda Lima', 26, 14, 0, 'reuniao', 'confirmado'), conf('c2', 'l03', 'Reunião comercial — Renata Souza', 26, 15, 0, 'reuniao', 'pendente'), conf('c3', 'l06', 'Assinatura de contrato — Tiago Tavares', 27, 14, 0, 'reuniao', 'confirmado'), conf('c4', 'l11', 'Ação externa — Veronica Menezes', 28, 14, 0, 'evento', 'pendente'), conf('c5', 'l12', 'Gravação de entrevista — Pedro Milagre', 29, 10, 0, 'gravacao', 'confirmado'), conf('c6', 'l07', 'Estreia da campanha — Aline Ribeiro', 26, 12, 0, 'evento', 'confirmado'), ]; function conf(id, lead_id, titulo, dia, hh, mm, tipo, status) { const dt = new Date(); dt.setDate(dia); dt.setHours(hh, mm, 0, 0); return { id, lead_id, titulo, quando: dt.toISOString(), tipo, status }; } /* ── Mocks: pipeline_estagios e allowed_users (só usados em DEMO_MODE — real vem do backend). ── */ const EST_MOCK = [ { id: 'novo', nome: 'Novos Leads', cor: '#3B82F6', ordem: 1, tipo: 'automatico', criterios: 'Lead entrou em contato pela primeira vez e ainda não foi qualificado.' }, { id: 'qualificacao', nome: 'Em Qualificação', cor: '#F59E0B', ordem: 2, tipo: 'manual', criterios: null }, { id: 'oportunidade', nome: 'Oportunidade', cor: '#8B5CF6', ordem: 3, tipo: 'manual', criterios: null }, { id: 'fechamento', nome: 'Fechamento', cor: '#10B981', ordem: 4, tipo: 'manual', criterios: null }, { id: 'ganho', nome: 'Ganho', cor: '#059669', ordem: 5, tipo: 'automatico', criterios: 'Contrato ou anúncio confirmado.' }, { id: 'perdido', nome: 'Perdido', cor: '#DC2626', ordem: 6, tipo: 'automatico', criterios: 'Lead disse explicitamente que não tem mais interesse.' }, ]; const TEAM_MOCK = [ { email: 'admin@radio-exemplo.test', role: 'admin' }, { email: 'comercial@radio-exemplo.test', role: 'atendente' }, { email: 'producao@radio-exemplo.test', role: 'atendente' }, ]; // Aviso de reunião confirmada: destinatários fictícios, só em DEMO_MODE // (nunca contato real de ninguém em dado de demonstração). const NOTIF_MOCK = [ { id: 'demo_comercial', nome: 'Comercial (demo)', telefone: '5500900000001', ativo: true, created_at: d(1) }, { id: 'demo_producao', nome: 'Produção (demo)', telefone: '5500900000002', ativo: true, created_at: d(1) }, ]; /* ── Agregações do dashboard — DEMO (mock fixo, inalterado) ── */ function computeDashboard() { const hoje = LEADS.filter((l) => Date.now() - new Date(l.ultima_interacao).getTime() < 24 * 3600e3); const conv = LEADS.filter((l) => l.status === 'ganho' || l.status === 'fechamento').length; const novos = LEADS.filter((l) => l.status === 'novo' && Date.now() - new Date(l.criado_em).getTime() < 24 * 3600e3).length; const serie = [6, 14, 10, 22, 16, 27, 20, 31, 24]; return { atendimentosHoje: hoje.length, atendimentosDelta: +18, conversoes: conv, conversoesDelta: +100, tempoMedio: '2m 10s', tempoDelta: -12, novosLeads: novos, novosDelta: novos ? +25 : -100, serieVolume: serie, conversoesPeriodo: conv, }; } /* ── Agregações do dashboard — REAL (07/10/2026, portado de tina-painel-dashboard-20261007). Calculado em cima do MESMO `leads`/`sessions` que o app.jsx já buscou (arrays paginados de Data.leads()/Data.sessions(), recebidos por parâmetro) — sem round-trip novo ao Supabase. Mesma definição de cada campo usada na Tina (ver relatório de entrega em state/tina-painel-dashboard-20261007/ e state/nanda-golive-20261007/): - Atendimentos Hoje: nº de sessões (agente 'vendas' — única categoria nesta instância) com pelo menos 1 mensagem desde 00:00 de hoje em America/Sao_Paulo. - Novos Leads: nº de leads com criado_em desde 00:00 de hoje (SP). - Conversões: SNAPSHOT atual — leads com status normalizado ganho OU fechamento, SEM recorte por data (sem coluna de "quando entrou em ganho" no schema) -> sem baseline de ontem -> delta null. - Tempo Médio: média dos tempos de resposta do agente HOJE (par adjacente cliente->agente no histórico, mensagem do cliente caindo na janela de hoje). Zero pares válidos -> 'sem dados'. - Porcentagens: janela DECORRIDA de hoje (00:00 SP até agora) vs a MESMA janela decorrida de ontem. Sem base em qualquer lado -> null (U.Delta já renderiza "–"). - Gráfico (serieVolume): mesma definição de "Atendimentos Hoje", um ponto por dia, últimos 9 dias (SP). Fuso: Intl.DateTimeFormat (America/Sao_Paulo, hoje fixo UTC-3, sem DST desde 2019 no Brasil — offset CALCULADO, nunca hardcoded). ── */ function spOffsetInfoAt(atMs) { const fmt = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Sao_Paulo', hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); const p = {}; fmt.formatToParts(new Date(atMs)).forEach((x) => { if (x.type !== 'literal') p[x.type] = x.value; }); const asUtc = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second); return { offsetMs: asUtc - atMs, y: +p.year, mo: +p.month - 1, d: +p.day }; } function spMidnightMsAt(atMs) { const info = spOffsetInfoAt(atMs); return Date.UTC(info.y, info.mo, info.d, 0, 0, 0) - info.offsetMs; } function fmtDuration(ms) { const total = Math.round(ms / 1000); const mm = Math.floor(total / 60), ss = total % 60; return (mm > 0 ? mm + 'm ' : '') + ss + 's'; } function pctDelta(hojeV, ontemV) { if (!ontemV) return null; return Math.round(((hojeV - ontemV) / ontemV) * 100); } function sessoesComMsgEm(sessoes, ini, fim) { return sessoes.filter((s) => (s.historico || []).some((mm) => { const t = mm && mm.timestamp ? new Date(mm.timestamp).getTime() : NaN; return isFinite(t) && t >= ini && t < fim; })).length; } function temposRespostaEm(sessoes, ini, fim) { const out = []; for (const s of sessoes) { const hist = s.historico || []; for (let i = 0; i < hist.length - 1; i++) { const cur = hist[i], nxt = hist[i + 1]; if (!cur || !nxt || cur.fromMe !== false || nxt.fromMe !== true) continue; const tCur = cur.timestamp ? new Date(cur.timestamp).getTime() : NaN; const tNxt = nxt.timestamp ? new Date(nxt.timestamp).getTime() : NaN; if (!isFinite(tCur) || !isFinite(tNxt) || tCur < ini || tCur >= fim) continue; const delta = tNxt - tCur; if (delta > 0) out.push(delta); } } return out; } const mediaMs = (arr) => (arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : null); function computeDashboardReal(leads, sessions) { leads = leads || []; sessions = sessions || []; const vendas = sessions.filter((s) => s.agente === 'vendas'); const agoraMs = Date.now(); const hojeIni = spMidnightMsAt(agoraMs); const decorrido = agoraMs - hojeIni; const ontemIni = hojeIni - 86400000; const ontemFim = ontemIni + decorrido; const atendHoje = sessoesComMsgEm(vendas, hojeIni, agoraMs); const atendOntem = sessoesComMsgEm(vendas, ontemIni, ontemFim); const criadosEm = (ini, fim) => leads.filter((l) => { const t = l.criado_em ? new Date(l.criado_em).getTime() : NaN; return isFinite(t) && t >= ini && t < fim; }).length; const novosHoje = criadosEm(hojeIni, agoraMs); const novosOntem = criadosEm(ontemIni, ontemFim); const conv = leads.filter((l) => l.status === 'ganho' || l.status === 'fechamento').length; const respHoje = temposRespostaEm(vendas, hojeIni, agoraMs); const respOntem = temposRespostaEm(vendas, ontemIni, ontemFim); const mHoje = mediaMs(respHoje); const mOntem = mediaMs(respOntem); const DIAS = 9; const serie = []; for (let i = DIAS - 1; i >= 0; i--) { const ini = hojeIni - i * 86400000; serie.push(sessoesComMsgEm(vendas, ini, ini + 86400000)); } return { atendimentosHoje: atendHoje, atendimentosDelta: pctDelta(atendHoje, atendOntem), conversoes: conv, conversoesDelta: null, conversoesPeriodo: conv, tempoMedio: mHoje == null ? 'sem dados' : fmtDuration(mHoje), tempoDelta: (mHoje == null || mOntem == null) ? null : pctDelta(mHoje, mOntem), novosLeads: novosHoje, novosDelta: pctDelta(novosHoje, novosOntem), serieVolume: serie, }; } /* ── Cliente Supabase (só criado no caminho real, com ANON key). Instância única compartilhada por Auth (login) e dados (RLS). ── */ let sb = null; function client() { if (sb) return sb; if (!CFG.SUPABASE_URL || !CFG.SUPABASE_ANON_KEY || !window.supabase) return null; sb = window.supabase.createClient(CFG.SUPABASE_URL, CFG.SUPABASE_ANON_KEY, { auth: { persistSession: true, autoRefreshToken: true, // painel usa hash routing (#/rota); não é callback de OAuth -> desligar evita o cliente // tentar interpretar o hash como retorno de auth. detectSessionInUrl: false, // lock passthrough: sem isto o supabase-js usa navigator.locks; se o getSession() do // boot segura o lock e trava, o signInWithPassword espera o mesmo lock pra sempre // ("Entrando…" infinito). Node não tem navigator.locks, por isso só quebra no navegador. lock: (_name, _acquireTimeout, fn) => fn(), }, }); return sb; } /* ── Mapeamento schema REAL -> shape do painel ── leads: jsonb (contato/analise/crm) + pipeline_estagio_id; sessions: data.historico com {role,content,direcao,timestamp(ms)}; confirmacoes: evento_summary/hora_evento. */ const norm = (s) => (s == null ? '' : String(s)).trim().toLowerCase(); function stageKeyFromName(nome) { const n = norm(nome); if (n.indexOf('novo') >= 0) return 'novo'; if (n.indexOf('qualific') >= 0) return 'qualificacao'; if (n.indexOf('oportunidade') >= 0) return 'oportunidade'; if (n.indexOf('fechamento') >= 0) return 'fechamento'; if (n.indexOf('ganho') >= 0 || n.indexOf('cliente') >= 0) return 'ganho'; if (n.indexOf('perdido') >= 0 || n.indexOf('perda') >= 0) return 'perdido'; return null; } const STATUS_KEY = { novo: 'novo', new: 'novo', lead: 'novo', qualificacao: 'qualificacao', qualificando: 'qualificacao', oportunidade: 'oportunidade', fechamento: 'fechamento', ganho: 'ganho', cliente: 'ganho', convertido: 'ganho', fechado: 'ganho', perdido: 'perdido', perda: 'perdido', }; function prioFromScore(sc) { const n = Number(sc); if (!isFinite(n) || n === 0) return 'media'; if (n >= 70) return 'alta'; if (n >= 40) return 'media'; return 'baixa'; } function normMsg(mm) { mm = mm || {}; const fromMe = mm.direcao === 'out' || mm.role === 'assistant' || mm.fromMe === true || mm.fromBot === true; const ts = mm.timestamp != null ? mm.timestamp : mm.time; let iso; if (typeof ts === 'number') iso = new Date(ts < 1e12 ? ts * 1000 : ts).toISOString(); else if (ts) iso = ts; else iso = new Date().toISOString(); return { fromMe, text: mm.content || mm.texto || mm.text || mm.mensagem || '', type: mm.type || 'text', timestamp: iso }; } let _stageMap = null; async function stageMap() { if (_stageMap) return _stageMap; const c = client(); if (!c) return {}; try { const { data } = await c.from('pipeline_estagios').select('id,nome'); const m = {}; (data || []).forEach((s) => { const k = stageKeyFromName(s.nome); if (k) m[s.id] = k; }); _stageMap = m; return m; } catch (e) { return {}; } } /* ── Auth para o backend: anexa o token da sessão Supabase a TODA chamada (o backend valida sessão + profiles.role='admin' em todas as rotas de leitura e escrita). Sem sessão, segue sem header (o backend responde 401/403 e a UI mostra o erro). ── */ async function bearer() { try { const c = client(); if (!c) return null; const { data } = await c.auth.getSession(); return (data && data.session && data.session.access_token) || null; } catch (e) { return null; } } async function apiFetch(path, opts) { opts = opts || {}; const headers = Object.assign({ 'content-type': 'application/json' }, opts.headers || {}); const t = await bearer(); if (t) headers['authorization'] = 'Bearer ' + t; return fetch(CFG.NANDA_BACKEND + path, Object.assign({}, opts, { headers })); } /* ── Paginação defensiva (portado de tina-painel-paginacao-20261007 em 07/10/2026): o PostgREST (Supabase) devolve no máximo 1.000 linhas por consulta. `buildPage(inicio, fim)` é chamado de novo a cada volta e deve devolver um construtor de consulta NOVO (mesmo select/filtros/order do caller, só acrescentando .range) — nunca reusar um builder já executado. Para na página que vier com menos de PAGE_SIZE linhas (fim real dos dados) ou no primeiro erro, sem retry. `pkField` (opcional): coluna que identifica a linha de forma única — cada chamador DEVE incluir essa mesma coluna como último critério do .order() (desempate estável) e, com pkField, o laço deduplica ao concatenar páginas (mantém a 1ª ocorrência). ── */ const PAGE_SIZE = 1000; async function fetchAllPages(buildPage, pkField) { let all = []; const vistos = pkField ? new Set() : null; let inicio = 0; for (;;) { const { data, error } = await buildPage(inicio, inicio + PAGE_SIZE - 1); if (error) return { data: null, error }; const pagina = data || []; if (vistos) { for (const linha of pagina) { const k = linha ? linha[pkField] : undefined; if (k != null) { if (vistos.has(k)) continue; vistos.add(k); } all.push(linha); } } else { all = all.concat(pagina); } if (pagina.length < PAGE_SIZE) break; inicio += PAGE_SIZE; } return { data: all, error: null }; } const Data = { DEMO, CFG, COLUMNS, client, statusLabel: (k) => STATUS_LABEL[k] || k, prioLabel: (k) => PRIO_LABEL[k] || k, async health() { if (DEMO) return { connected: true, smartphoneConnected: true, demo: true, pct: 100 }; try { const r = await apiFetch('/status', { headers: { accept: 'application/json' } }); const j = await r.json(); return { connected: !!j.connected, smartphoneConnected: !!j.smartphoneConnected, error: j.error, pct: j.connected ? 100 : 40 }; } catch (e) { return { connected: false, error: String(e), pct: 0 }; } }, async leads() { if (DEMO) return LEADS.slice(); const c = client(); if (!c) return LEADS.slice(); const sm = await stageMap(); const { data, error } = await fetchAllPages((ini, fim) => c.from('leads').select('*').order('atualizado_em', { ascending: false }).order('id', { ascending: true }).range(ini, fim) , 'id'); if (error) throw error; return (data || []).map((l) => normLead(l, sm)); }, async sessions() { // canal: dado JÁ existe em sessions.data.canal (gravado pelo webhook em cada // sessão — 'whatsapp' ou 'instagram'), só não estava sendo lido/exposto aqui. // Mesma checagem binária usada no backend (webhook.js): só 'instagram' é // tratado como Instagram, qualquer outra coisa (ou ausência) vira WhatsApp. // // AGENTE: sempre 'vendas' nesta instância (a Nanda tem UM fluxo, UM número). const tagVendas = (arr) => arr.map((s) => ({ ...s, agente: 'vendas' })); const demoAll = () => tagVendas(SESSIONS.map((s) => ({ ...s, historico: s.data.historico, canal: s.data.canal === 'instagram' ? 'instagram' : 'whatsapp' }))); if (DEMO) return demoAll(); const c = client(); if (!c) return demoAll(); // telefone -> lead (para lead_id e estado pausado); não quebra se falhar const telMap = {}; try { const { data: lr } = await fetchAllPages((ini, fim) => c.from('leads').select('id,telefone,modo_manual').order('id', { ascending: true }).range(ini, fim) , 'id'); (lr || []).forEach((l) => { if (l.telefone) telMap[l.telefone] = l; }); } catch (e) { /* segue sem enrich */ } const { data, error } = await fetchAllPages((ini, fim) => c.from('sessions').select('*').order('updated_at', { ascending: false }).order('telefone', { ascending: true }).range(ini, fim) , 'telefone'); if (error) throw error; const vendas = (data || []).map((s) => { const sd = s.data || {}; // filter(Boolean): a sync do CRM grava data sem historico e nada garante // que um item do array não venha null — item nulo não pode chegar na UI. const hist = Array.isArray(sd.historico) ? sd.historico.filter(Boolean).map(normMsg) : []; const lead = telMap[s.telefone]; return { id: s.telefone, lead_id: lead ? lead.id : s.telefone, telefone: s.telefone, pausado: lead ? !!lead.modo_manual : false, atualizado_em: s.updated_at, historico: hist, canal: sd.canal === 'instagram' ? 'instagram' : 'whatsapp', agente: 'vendas', }; }); return vendas.sort((a, b) => new Date(b.atualizado_em || 0) - new Date(a.atualizado_em || 0)); }, // Permissão de vendas (trava REAL no banco, RPC SECURITY DEFINER // `pode_ver_vendas()`). Fail-OPEN: erro de rede NÃO tranca os usuários // existentes — a trava real de dado é a RLS de sessions/leads/confirmacoes, // que já devolve zero linha pra quem não tem permissão. (Instância de UM // fluxo: só ver_vendas importa aqui; o resto do bloco original cobria telas // clone — tirado nesta rodada de polish, 12/09/2026.) async perms() { if (DEMO) return { verVendas: true }; const c = client(); if (!c) return { verVendas: true }; try { const r = await c.rpc('pode_ver_vendas'); return { verVendas: r.error ? true : r.data === true }; } catch (e) { return { verVendas: true }; } }, async confirmacoes() { if (DEMO) return CONFIRM.slice(); const c = client(); if (!c) return CONFIRM.slice(); // Fonte 1: tabela confirmacoes (lembretes de véspera / Google Calendar). const { data: confs } = await fetchAllPages((ini, fim) => c.from('confirmacoes').select('*').order('enviado_em', { ascending: true }).order('evento_id', { ascending: true }).range(ini, fim) , 'evento_id'); const doConfirm = (confs || []).map((x) => ({ id: x.evento_id, lead_id: null, telefone: x.telefone, titulo: x.evento_summary || 'Evento', quando: x.hora_evento || null, tipo: x.metodo || 'evento', status: 'pendente', })); // Fonte 2: a AGENDA REAL: leads que a Nanda marcou como reuniao_agendada e têm data. // (antes o painel só lia 'confirmacoes', que fica vazia; por isso a agenda não aparecia.) const { data: ags } = await fetchAllPages((ini, fim) => c.from('leads') .select('id,nome,telefone,reuniao_data,reuniao_horario,status') .eq('status', 'reuniao_agendada').not('reuniao_data', 'is', null) .order('id', { ascending: true }) .range(ini, fim) , 'id'); const doLeads = (ags || []).map((l) => { const hora = (l.reuniao_horario && /^\d{1,2}:\d{2}$/.test(l.reuniao_horario)) ? l.reuniao_horario : '09:00'; return { id: 'ag_' + l.id, lead_id: l.id, telefone: l.telefone, titulo: (l.nome || 'Lead') + ' — Reunião', quando: l.reuniao_data + 'T' + hora + ':00', tipo: 'reuniao', status: 'agendado', }; }); return [...doConfirm, ...doLeads]; }, // leads/sessions: os mesmos arrays que o app.jsx já buscou (paginados) — // passados por parâmetro pra não refazer round-trip ao Supabase. Em DEMO // seguem ignorados (mock fixo, igual às outras funções deste arquivo). async dashboard(leads, sessions) { return DEMO ? computeDashboard() : computeDashboardReal(leads, sessions); }, // Envio: no real, SEMPRE via backend (Zernio server-side). Nunca do front. // Rota real do backend é /api/enviar-mensagem com campo `texto` (não `mensagem`) // (conferido no código do backend, src/webhook.js). async sendMessage(lead, text, agente) { if (DEMO) { const s = SESSIONS.find((x) => x.lead_id === lead.id); if (s) s.data.historico.push(m(true, text, 0)); return { ok: true, demo: true }; } // agente: sempre 'vendas' nesta instância (único número/conta Zernio da Nanda). // SEGURANÇA: a rota exige // guarda no backend (requireEnvioOuAdmin, ver src/authGuard.js) e precisa // do Bearer da sessão, senão volta 401. Trocado o fetch cru por apiFetch // (mesmo padrão de setManual/createLead/updateLead logo abaixo), que já // anexa o token via bearer(). Nada mais mudou nesta função. const r = await apiFetch('/api/enviar-mensagem', { method: 'POST', body: JSON.stringify({ telefone: lead.telefone, texto: text, operador: 'Painel', agente: agente || 'vendas' }), }); return r.json(); }, // Assumir/retomar a conversa: liga/desliga leads.modo_manual (por telefone) // no backend. Rota protegida (requireAdmin) — apiFetch anexa o token da // sessão. Com manual=true a Nanda para de responder esse lead (checado no // webhook); manual=false devolve a conversa pra IA. async setManual(lead, manual) { if (DEMO) return { ok: true, demo: true, modo_manual: !!manual }; const r = await apiFetch('/api/lead-modo-manual', { method: 'POST', body: JSON.stringify({ telefone: lead.telefone, manual: !!manual }), }); const j = await r.json(); if (!j.success) throw new Error(j.error || 'Falha ao mudar modo manual'); return { ok: true, modo_manual: j.modo_manual, leads_afetados: j.leads_afetados }; }, // ── Pipeline: criação manual de lead (Pipeline "Novo deal" / Contatos "Novo contato") ── async createLead({ nome, telefone, prioridade, coluna }) { if (DEMO) { const novo = mkLead('demo_' + Date.now(), nome, telefone, coluna || 'novo', prioridade || 'media', [], 'Qualificar', 0, 0, null); LEADS.unshift(novo); return { ok: true, lead: novo }; } const r = await apiFetch('/api/leads', { method: 'POST', body: JSON.stringify({ nome, telefone, prioridade, coluna }), }); const j = await r.json(); if (!j.success) throw new Error(j.error || 'Falha ao criar lead'); return { ok: true, lead: normLead(j.lead, await stageMap()) }; }, // ── Contatos/Pipeline/Chat: edição de lead pelo Admin ("Editar lead") ── async updateLead(id, patch) { if (DEMO) { const i = LEADS.findIndex((l) => l.id === id); if (i >= 0) LEADS[i] = { ...LEADS[i], ...patch }; return { ok: true, lead: LEADS[i] }; } const r = await apiFetch('/api/leads/' + encodeURIComponent(id), { method: 'PATCH', body: JSON.stringify(patch), }); const j = await r.json(); if (!j.success) throw new Error(j.error || 'Falha ao atualizar lead'); return { ok: true, lead: normLead(j.lead, await stageMap()) }; }, // ── Pipeline: etapas (módulo "Configurar" do Pipeline) ── async pipelineEstagios() { if (DEMO) return EST_MOCK.map((e) => ({ ...e })); const r = await apiFetch('/api/pipeline-estagios'); const j = await r.json(); if (!j.success) throw new Error(j.error || 'Falha ao carregar etapas'); return j.estagios; }, async createEstagio({ nome, cor, tipo, criterios }) { if (DEMO) { const novo = { id: 'demo_' + Date.now(), nome, cor: cor || '#5aa2ff', ordem: EST_MOCK.length + 1, tipo: tipo === 'automatico' ? 'automatico' : 'manual', criterios: tipo === 'automatico' ? (criterios || '') : null }; EST_MOCK.push(novo); return { ok: true, estagio: novo }; } const r = await apiFetch('/api/pipeline-estagios', { method: 'POST', body: JSON.stringify({ nome, cor, tipo, criterios }), }); const j = await r.json(); if (!j.success) throw new Error(j.error || 'Falha ao criar etapa'); return j; }, async updateEstagio(id, patch) { if (DEMO) { const i = EST_MOCK.findIndex((e) => e.id === id); if (i >= 0) EST_MOCK[i] = { ...EST_MOCK[i], ...patch }; return { ok: true }; } const r = await apiFetch('/api/pipeline-estagios/' + encodeURIComponent(id) + '/atualizar', { method: 'POST', body: JSON.stringify(patch), }); const j = await r.json(); if (!j.success) throw new Error(j.error || 'Falha ao atualizar etapa'); return j; }, async deleteEstagio(id) { if (DEMO) { const i = EST_MOCK.findIndex((e) => e.id === id); if (i >= 0) EST_MOCK.splice(i, 1); return { ok: true }; } const r = await apiFetch('/api/pipeline-estagios/' + encodeURIComponent(id) + '/excluir', { method: 'POST' }); const j = await r.json(); if (!j.success) throw new Error(j.error || 'Falha ao excluir etapa'); return j; }, // ── Equipe: allowlist de acesso ao painel (Equipe "Convidar usuário" / "Configurar") ── async teamUsers() { if (DEMO) return TEAM_MOCK.map((u) => ({ ...u })); const r = await apiFetch('/api/team/usuarios'); const j = await r.json(); if (!j.success) throw new Error(j.error || 'Falha ao carregar usuários'); return j.usuarios; }, async inviteUser({ email, role }) { if (DEMO) { const idx = TEAM_MOCK.findIndex((u) => u.email === email); const row = { email, role: role === 'admin' ? 'admin' : 'atendente' }; if (idx >= 0) TEAM_MOCK[idx] = row; else TEAM_MOCK.push(row); return { ok: true, usuario: row }; } const r = await apiFetch('/api/team/convidar', { method: 'POST', body: JSON.stringify({ email, role }), }); const j = await r.json(); if (!j.success) throw new Error(j.error || 'Falha ao convidar usuário'); return j; }, async removeTeamUser(email) { if (DEMO) { const i = TEAM_MOCK.findIndex((u) => u.email === email); if (i >= 0) TEAM_MOCK.splice(i, 1); return { ok: true }; } const r = await apiFetch('/api/team/remover', { method: 'POST', body: JSON.stringify({ email }), }); const j = await r.json(); if (!j.success) throw new Error(j.error || 'Falha ao remover usuário'); return j; }, // ── Aviso de Confirmação de Visita: destinatários (Configurações → Notificações) ── // Quando um CLIENTE confirma reunião, a Nanda avisa cada destinatário // ativo aqui via WhatsApp (template Meta "confirmacao_visita_nanda"). Lista // gerenciada só por Admin; backend valida de novo (rota não checa papel, // a trava de UI é só UX — mesma ressalva de ADMIN_ONLY em app.jsx). async notifDestinatarios() { if (DEMO) return NOTIF_MOCK.slice(); const r = await apiFetch('/api/notif-destinatarios'); const j = await r.json(); if (!j.success) throw new Error(j.error || 'Falha ao carregar destinatários'); return j.destinatarios; }, async addNotifDestinatario({ nome, telefone }) { if (DEMO) { const row = { id: 'demo_' + Date.now(), nome, telefone, ativo: true, created_at: new Date().toISOString() }; NOTIF_MOCK.push(row); return { ok: true, destinatario: row }; } const r = await apiFetch('/api/notif-destinatarios', { method: 'POST', body: JSON.stringify({ nome, telefone }), }); const j = await r.json(); if (!j.success) throw new Error(j.error || 'Falha ao adicionar destinatário'); return j; }, async removeNotifDestinatario(id) { if (DEMO) { const i = NOTIF_MOCK.findIndex((x) => x.id === id); if (i >= 0) NOTIF_MOCK.splice(i, 1); return { ok: true }; } const r = await apiFetch('/api/notif-destinatarios/' + encodeURIComponent(id) + '/excluir', { method: 'POST' }); const j = await r.json(); if (!j.success) throw new Error(j.error || 'Falha ao remover destinatário'); return j; }, // ── Configurações → aba Agente: prompt REAL da Nanda (leitura; edição só admin) ── async prompt() { if (DEMO) { return { prompt: 'Modo demonstração — sem prompt real carregado (configure SUPABASE_ANON_KEY em config.js para ver o prompt real).', arquivo: '-', tamanho: 0, atualizado_em: null, versao: null, fonte: 'demo' }; } const r = await apiFetch('/api/prompt'); const j = await r.json(); if (!j.success) throw new Error(j.error || 'Falha ao carregar prompt'); return { prompt: j.prompt, arquivo: j.arquivo, tamanho: j.tamanho, atualizado_em: j.atualizado_em, versao: j.versao, fonte: j.fonte, atualizado_por: j.atualizado_por }; }, // ── Configurações → Agente: SALVAR novo prompt. Só admin: o backend valida a // sessão Supabase + profiles.role antes de gravar (não-admin -> 403). Cria uma // nova versão em agent_prompt e passa a valer AO VIVO na próxima mensagem. ── async savePrompt(conteudo) { if (DEMO) return { ok: true, demo: true, versao: 0 }; const r = await apiFetch('/api/prompt', { method: 'POST', body: JSON.stringify({ conteudo }) }); let j = null; try { j = await r.json(); } catch (e) { j = null; } if (!r.ok || !j || !j.success) { throw new Error((j && j.error) || ('Falha ao salvar (HTTP ' + r.status + ')')); } return { ok: true, versao: j.versao, atualizado_em: j.atualizado_em, atualizado_por: j.atualizado_por, tamanho: j.tamanho }; }, }; /* Busca defensiva de chaves ainda não confirmadas no schema real (objeções/desejo) — tenta variações plausíveis do nome; se o backend não salvar nenhuma delas, volta vazio e a UI mostra placeholder (nunca inventa dado). */ const pickArr = (obj, keys) => { for (const k of keys) { if (Array.isArray(obj[k]) && obj[k].length) return obj[k]; } return []; }; const pickStr = (obj, keys) => { for (const k of keys) { if (typeof obj[k] === 'string' && obj[k].trim()) return obj[k].trim(); } return ''; }; function normLead(l, sm) { l = l || {}; const contato = l.contato || {}; const analise = l.analise || {}; const stKey = (l.pipeline_estagio_id && sm && sm[l.pipeline_estagio_id]) || STATUS_KEY[norm(l.status)] || 'novo'; const dores = pickArr(analise, ['doresIdentificadas', 'dores']); const interesses = (Array.isArray(analise.oportunidades) && analise.oportunidades.length) ? analise.oportunidades : dores; // Objeções e desejo: campos ainda não confirmados no schema (ver relatório). const objecoes = pickArr(analise, ['objecoes', 'objecoesIdentificadas', 'objeções']); const desejo = pickStr(analise, ['desejo', 'desejoPrincipal', 'desejoIdentificado']); const ult = contato.ultimaInteracaoEm || l.atualizado_em || l.criado_em || null; return { id: l.id, nome: l.nome || 'Sem nome', telefone: l.telefone || '', email: l.email || '', status: stKey, prioridade: prioFromScore(analise.scoreGeral != null ? analise.scoreGeral : analise.scorePotencial), canal: contato.canal || 'whatsapp', valor: Number(l.proposta_valor || 0), responsavel: l.operador || null, criado_em: l.criado_em || null, ultima_interacao: ult, total_conversas: Array.isArray(contato.historico) ? contato.historico.length : 0, memoria: { interesses, proxima_acao: analise.abordagemSugerida || '', dores, objecoes, desejo }, }; } window.App = window.App || {}; window.App.Data = Data; })();