/* Painel Nanda — telas B: Treinamento, Agendamentos, Equipe, Ajuda, Configurações. window.App.ScreensB */ (function () { const I = window.App.Icons; const U = window.App.UI; const D = window.App.Data; const CFG = window.NANDA_CONFIG || {}; const BR = CFG.brand || {}; const { useState, useEffect } = React; const AG = BR.agente || 'Nanda'; /* ─────────────── TREINAMENTO ─────────────── */ function Training() { const tabs = ['Fatos', 'Documentos', 'Dúvidas', 'Simulador', 'Testes']; const [tab, setTab] = useState('Fatos'); const trilha = [ ['Fatos essenciais', 'Cadastre preço, link e horário'], ['Base de busca', 'Adicione FAQ ou material'], ['Dúvidas em dia', '10 abertas'], ['Golden set', '0 de 10 casos'], ['Avaliação ≥ 80%', 'Rode a primeira avaliação'], ]; const empty = { Fatos: ['Nenhum fato cadastrado', 'Cadastre verdades que a ' + AG + ' repete palavra por palavra (preço, link, horário).'], Documentos: ['Nenhum documento na base', 'Suba FAQ, tabela de valores ou materiais — viram base de busca (RAG).'], Dúvidas: ['10 dúvidas abertas', 'Perguntas que a ' + AG + ' não soube responder. Transforme cada uma em fato.'], Simulador: ['Simulador de conversa', 'Converse com a ' + AG + ' em ambiente de teste antes de liberar pro cliente.'], Testes: ['Casos de teste (0 ativos)', 'Monte o golden set e rode a avaliação automática (meta ≥ 80%).'], }; return (
TRILHA DO TREINAMENTO0/5
{trilha.map(([t, s], i) => (
{i + 1}
{t}
{s}
))}
{tabs.map((t) => ( ))}
{empty[tab][0]}
{empty[tab][1]}
Adicionar
); } /* ─────────────── AGENDAMENTOS ─────────────── */ function Scheduling({ data }) { const [view, setView] = useState('Mês'); const now = new Date(); // Mês exibido no calendário (independente do mês real) — sem isto o grid ficava // sempre travado em new Date() e os botões de navegação não tinham pra onde escrever. const [cursor, setCursor] = useState(() => new Date(now.getFullYear(), now.getMonth(), 1)); const y = cursor.getFullYear(), mo = cursor.getMonth(); const first = new Date(y, mo, 1).getDay(); const days = new Date(y, mo + 1, 0).getDate(); const evByDay = {}; (data.confirm || []).forEach((c) => { const dt = new Date(c.quando); if (dt.getFullYear() !== y || dt.getMonth() !== mo) return; // não vazar evento de outro mês pro mesmo número de dia const dd = dt.getDate(); (evByDay[dd] = evByDay[dd] || []).push(c); }); const cells = []; for (let i = 0; i < first; i++) cells.push(null); for (let dd = 1; dd <= days; dd++) cells.push(dd); const wd = ['DOM', 'SEG', 'TER', 'QUA', 'QUI', 'SEX', 'SÁB']; const monthName = cursor.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }); const goPrevMonth = () => setCursor((d) => new Date(d.getFullYear(), d.getMonth() - 1, 1)); const goNextMonth = () => setCursor((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1)); return (
{['Mês', 'Semana', 'Dia'].map((v) => )}
Agendar} />
{monthName}
{wd.map((w) =>
{w}
)}
{cells.map((dd, i) => { const evs = dd ? (evByDay[dd] || []) : []; const today = dd === now.getDate() && mo === now.getMonth() && y === now.getFullYear(); return (
{dd && <>
{dd}
{evs.slice(0, 3).map((e) => (
{new Date(e.quando).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })} {e.titulo.split(' — ')[0]}
))}
}
); })}
); } /* ─────────────── CONVIDAR USUÁRIO (Equipe) ─────────────── */ function InviteUserModal({ open, onClose }) { const [email, setEmail] = useState(''); const [role, setRole] = useState('atendente'); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const [done, setDone] = useState(null); useEffect(() => { if (open) { setEmail(''); setRole('atendente'); setBusy(false); setErr(null); setDone(null); } }, [open]); const submit = async () => { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { setErr('Digite um e-mail válido.'); return; } setBusy(true); setErr(null); try { await D.inviteUser({ email: email.trim().toLowerCase(), role }); setDone(email.trim()); } catch (e) { setErr(String(e.message || e)); } finally { setBusy(false); } }; return ( {done ? (
{done} liberado na allowlist. Atenção: o login aqui hoje é e-mail/senha (Google OAuth não está habilitado neste projeto) — a pessoa só consegue entrar depois que uma conta com senha for criada pra ela em Authentication → Users no Supabase, não é automático como seria com Google.
Fechar
) : (
setEmail(e.target.value)} placeholder="pessoa@exemplo.com" onKeyDown={(e) => e.key === 'Enter' && submit()} className="h-10 w-full rounded-lg border border-line bg-surface px-3 text-[13.5px] text-fg placeholder:text-faint outline-none focus:border-brand/60" />
{err &&
{err}
}
Cancelar {busy ? 'Enviando…' : 'Convidar'}
)}
); } /* ─────────────── CONFIGURAR EQUIPE (gerenciar allowlist de acesso) ─────────────── */ function ManageAccessModal({ open, onClose }) { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); const [busyEmail, setBusyEmail] = useState(null); const load = async () => { setLoading(true); setErr(null); try { setUsers(await D.teamUsers()); } catch (e) { setErr(String(e.message || e)); } finally { setLoading(false); } }; useEffect(() => { if (open) load(); }, [open]); const remove = async (email) => { if (!window.confirm(`Remover o acesso de ${email} ao painel?`)) return; setBusyEmail(email); try { await D.removeTeamUser(email); await load(); } catch (e) { setErr(String(e.message || e)); } finally { setBusyEmail(null); } }; return (
Quem tem acesso ao painel (allowlist). Login hoje é e-mail/senha — ver aviso em "Convidar usuário".
{err &&
{err}
} {loading ? (
Carregando…
) : (
{users.map((u) => (
{u.email}
{u.role === 'admin' ? 'Admin' : 'Atendente'}
))} {!users.length &&
Nenhum usuário na lista.
}
)}
Fechar
); } /* ─────────────── EQUIPE ─────────────── */ function Team() { const [inviteOpen, setInviteOpen] = useState(false); const [cfgOpen, setCfgOpen] = useState(false); const stats = [['Total de Usuários', 3, I.Contacts], ['Admins', 1, I.Team], ['Membros', 2, I.User], ['Times Ativos', 2, I.Activity]]; const members = [ ['Administração (exemplo)', 'admin@radio-exemplo.test', 'Admin', 'Atendimento', 'ok'], ['Comercial (exemplo)', 'comercial@radio-exemplo.test', 'Membro', 'Comercial', 'ok'], ['Produção (exemplo)', 'producao@radio-exemplo.test', 'Membro', 'Produção', 'ok'], ]; return (
setCfgOpen(true)}>Configurar setInviteOpen(true)}>Convidar usuário} />
{stats.map(([l, v, Ic]) => )}
Usuários da Equipe
Gerencie papéis e times dos usuários
{members.map(([nome, email, role, time]) => ( ))}
{nome}
{email}
{role} {time}
Esta tabela é ilustrativa. Quem realmente tem acesso ao painel está em "Configurar".
setInviteOpen(false)} /> setCfgOpen(false)} />
); } /* ─────────────── AJUDA ─────────────── */ function Help() { const faq = [ ['Como a ' + AG + ' recebe mensagens?', 'Via webhook da Zernio (WhatsApp Business API oficial, evento message.received). Cada mensagem cai no backend (Railway), que roda o motor de conversa e responde pelo mesmo número.'], ['Preciso de template aprovado pela Meta?', 'Sim, para iniciar conversa ou falar fora da janela de 24h. Dentro da janela aberta pelo contato o texto é livre. Os templates são criados e aprovados na WABA da rádio.'], ['A ' + AG + ' responde tudo sozinha?', 'Só quando o modo ativo estiver ligado no backend (NANDA_ATIVA=1). Pausada, ela registra o que chega e não responde nada. Em qualquer conversa você pode "Assumir" e conduzir manualmente; ao "Retomar", ela volta.'], ['Como conecto o número?', 'O número é cadastrado na Zernio (WABA), e o webhook da Zernio aponta para o backend em /webhook-zernio. Status em Dashboard, widget "Status do Sistema" (exige login).'], ['Onde ficam os segredos (tokens)?', 'Somente no backend, via variáveis de ambiente. O painel usa apenas a anon key do Supabase (protegida por RLS) e a sessão de login. Service_role e API key da Zernio nunca entram no front.'], ['E áudio?', 'Entrada de áudio pode ser transcrita (Whisper) e a resposta pode sair em voz (TTS), tudo server-side e desligável por variável de ambiente.'], ]; const [open, setOpen] = useState(0); return (
Checklist de ativação
    {['Cadastrar o número dedicado na Zernio (WABA da rádio).', 'Configurar o webhook message.received apontando pro backend (/webhook-zernio).', 'Subir o perfil da ' + AG + ' em Configurações > Agente antes de liberar.', 'Ligar o modo ativo (NANDA_ATIVA=1) e validar com um número da equipe.'].map((s, i) =>
  • {s}
  • )}
Perguntas frequentes
{faq.map(([q, a], i) => (
{open === i &&
{a}
}
))}
); } /* ─────────────── NOTIFICAÇÕES — Aviso de Confirmação de Visita ─────────── Quando o CLIENTE confirma reunião com a Nanda, o backend (ver src/notificacaoVisita.js) avisa cada destinatário ATIVO aqui via WhatsApp (template Meta "confirmacao_visita_nanda"). Esta seção só gerencia a LISTA (GET/POST/excluir em /api/notif-destinatarios) — quem está nela é só UX de Admin, o backend também confia na sessão/serviço, não recalcula papel aqui (mesma ressalva do resto do painel). */ function formatarTelDisplay(tel) { const dg = String(tel || '').replace(/\D/g, ''); if (dg.length === 13 && dg.startsWith('55')) return `+55 (${dg.slice(2, 4)}) ${dg.slice(4, 9)}-${dg.slice(9, 13)}`; return dg ? `+${dg}` : (tel || ''); } function NotifVisitaSettings() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); const [nome, setNome] = useState(''); const [telefone, setTelefone] = useState(''); const [busyAdd, setBusyAdd] = useState(false); const [busyId, setBusyId] = useState(null); const load = async () => { setLoading(true); setErr(null); try { setItems(await D.notifDestinatarios()); } catch (e) { setErr(String(e.message || e)); } finally { setLoading(false); } }; useEffect(() => { load(); }, []); const add = async () => { const digits = telefone.replace(/\D/g, ''); if (!nome.trim()) { setErr('Digite o nome do destinatário.'); return; } if (digits.length < 10) { setErr('Telefone inválido — use DDI+DDD+número, ex: 5524999998888.'); return; } setBusyAdd(true); setErr(null); try { await D.addNotifDestinatario({ nome: nome.trim(), telefone: digits }); setNome(''); setTelefone(''); await load(); } catch (e) { setErr(String(e.message || e)); } finally { setBusyAdd(false); } }; const remove = async (item) => { if (!window.confirm(`Remover ${item.nome} da lista de avisos de visita? A pessoa deixa de receber o aviso quando um cliente confirmar visita.`)) return; setBusyId(item.id); setErr(null); try { await D.removeNotifDestinatario(item.id); await load(); } catch (e) { setErr(String(e.message || e)); } finally { setBusyId(null); } }; return (
Aviso de Confirmação de Visita
Quando um cliente confirma visita ou reunião com a {AG}, cada destinatário ativo abaixo recebe um aviso automático por WhatsApp (template aprovado pela Meta) com nome do cliente, interesse, contato, objeções, dores e desejos identificados na conversa.
{err &&
{err}
} {loading ? (
Carregando…
) : (
{items.map((it) => (
{it.nome}
{formatarTelDisplay(it.telefone)}
))} {!items.length &&
Nenhum destinatário cadastrado — ninguém vai receber o aviso ainda.
}
)}
Adicionar destinatário
setNome(e.target.value)} placeholder="Nome" className="h-10 flex-1 rounded-lg border border-line bg-surface px-3 text-[13.5px] text-fg placeholder:text-faint outline-none focus:border-brand/60" /> setTelefone(e.target.value)} placeholder="Telefone (DDI+DDD+número, ex: 5524999998888)" onKeyDown={(e) => e.key === 'Enter' && add()} className="h-10 flex-1 rounded-lg border border-line bg-surface px-3 text-[13.5px] text-fg placeholder:text-faint outline-none focus:border-brand/60" /> {busyAdd ? 'Adicionando…' : 'Adicionar'}
); } /* ─────────────── CONFIGURAÇÕES ─────────────── */ function Field({ label, hint, children }) { return
{children}
; } const inputCls = 'h-10 w-full rounded-lg border border-line bg-surface px-3 text-[13.5px] text-fg placeholder:text-faint outline-none focus:border-brand/60'; function Settings({ isAdmin }) { const [tab, setTab] = useState('Agente'); const [promptData, setPromptData] = useState(null); const [promptLoading, setPromptLoading] = useState(true); const [promptErr, setPromptErr] = useState(null); // Edição do prompt (só admin): editing destrava o textarea; confirmOpen abre o // modal que avisa que a mudança vale AO VIVO antes de gravar. const [editing, setEditing] = useState(false); const [editValue, setEditValue] = useState(''); const [saving, setSaving] = useState(false); const [saveErr, setSaveErr] = useState(null); const [saveOk, setSaveOk] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); useEffect(() => { let alive = true; D.prompt() .then((r) => { if (alive) { setPromptData(r); setPromptLoading(false); } }) .catch((e) => { if (alive) { setPromptErr(String(e.message || e)); setPromptLoading(false); } }); return () => { alive = false; }; }, []); const reload = () => D.prompt().then((r) => setPromptData(r)).catch(() => {}); const startEdit = () => { setEditValue(promptData ? promptData.prompt : ''); setSaveErr(null); setSaveOk(null); setEditing(true); }; const cancelEdit = () => { setEditing(false); setSaveErr(null); }; const doSave = async () => { setConfirmOpen(false); setSaving(true); setSaveErr(null); setSaveOk(null); try { const res = await D.savePrompt(editValue); setEditing(false); setSaveOk('Prompt salvo e ativo (versão ' + (res && res.versao != null ? res.versao : '?') + '). A ' + AG + ' já usa a nova versão.'); await reload(); } catch (e) { setSaveErr(String(e.message || e)); } finally { setSaving(false); } }; const verLabel = promptData && promptData.versao != null ? ('v' + promptData.versao) : null; return (
{isAdmin ? 'Você é administrador' : 'Apenas administradores podem editar'}} />
{[['Agente', I.Bot], ['Canais', I.MsgCircle], ['Notificações', I.Send], ['APIs', I.Settings]].map(([t, Ic]) => ( ))}
{tab === 'Agente' &&
Prompt do Sistema
{editing ? Editando — vale ao vivo ao salvar : {isAdmin ? 'Leitura — clique em Editar' : 'Somente leitura'}} {isAdmin && !editing && ( Editar )} {isAdmin && editing && ( Cancelar setConfirmOpen(true)} disabled={saving || !editValue.trim()}>{saving ? 'Salvando…' : 'Salvar'} )}
{promptData && !editing && (
Fonte: {promptData.arquivo}{verLabel ? ' · ' + verLabel : ''} · {(promptData.tamanho || 0).toLocaleString('pt-BR')} caracteres {promptData.atualizado_em ? ' · atualizado em ' + new Date(promptData.atualizado_em).toLocaleString('pt-BR') : ''} {promptData.atualizado_por ? ' · por ' + promptData.atualizado_por : ''}
)} {editing && (
Isto muda o comportamento da {AG} AO VIVO assim que você salvar. Uma nova versão é criada e a anterior fica guardada no banco (dá pra reverter).
)} {saveOk &&
{saveOk}
} {saveErr &&
Não salvou: {saveErr}
}