);
}
/* Badge de canal (WhatsApp/Instagram) — cantinho do avatar, mesmo padrão do
pontinho de status (pausado/ativo) que já mora ali do lado, só que no canto
de cima. `border` recebe a cor de fundo ao redor (border-surface2 na lista,
border-bg no cabeçalho da thread) pra recortar limpo, igual o pontinho já faz. */
function ChannelBadge({ canal, border }) {
const insta = canal === 'instagram';
const Icon = insta ? I.Instagram : I.WhatsApp;
return (
);
}
/* ─────────────── CHAT AO VIVO ─────────────── */
function Chat({ data, isAdmin, perms }) {
// Refaz leads/sessions ao MONTAR (login, ou volta de outra aba — o
// roteador em app.jsx desmonta/remonta o componente a cada troca de
// rota), em vez de usar só o snapshot do login (portado de
// tina-painel-fix-manuais-20261001, aprovado Márcio msg 7164). useEffect
// com [] roda uma vez por montagem, sem polling contínuo. Falha de rede
// mantém os dados já carregados (nunca esvazia a tela).
const [sessionsLive, setSessionsLive] = useState(data.sessions);
const [leadsLive, setLeadsLive] = useState(data.leads);
useEffect(() => {
let alive = true;
(async () => {
try {
const [freshLeads, freshSessions] = await Promise.all([D.leads(), D.sessions()]);
if (alive) { setLeadsLive(freshLeads); setSessionsLive(freshSessions); }
} catch (e) { /* mantém os dados atuais; não derruba a tela por falha de rede */ }
})();
return () => { alive = false; };
}, []);
const sessions = sessionsLive;
const leadById = useMemo(() => Object.fromEntries(leadsLive.map((l) => [l.id, l])), [leadsLive]);
// Filtro por agente: a Nanda tem UM fluxo (WhatsApp), então o filtro nunca
// aparece (agentTabs sempre cai em 1 categoria real). Mantido inerte; se um
// dia houver um 2º número/funil, é aqui que se reativa.
const canVend = !perms || perms.verVendas !== false;
const agentTabs = [['todos', 'Todas'], canVend && ['vendas', 'Vendas']].filter(Boolean);
const showFilter = agentTabs.length > 2; // nunca true hoje (só 1 categoria real)
const soloAlt = null;
const [agFilter, setAgFilter] = useState('todos');
const effFilter = soloAlt || agFilter;
// Resolve os dados de exibição de uma sessão a partir do lead (leadById).
// Fallback defensivo pra sessão sem linha em `leads` ainda: nunca retorna
// null pra uma sessão válida -> sem crash.
const infoFor = (s) => {
if (!s) return null;
const l = leadById[s.lead_id];
if (l) return l;
// guarda (portado de tina-painel-fix-20261007): sem isso lead.memoria.*
// quebra o painel (TypeError) quando a sessão mais recente não tem lead casado.
return { id: s.lead_id, nome: s.nome || s.telefone || 'Contato', telefone: s.telefone || '', status: null, estado: s.estado, memoria: { interesses: [], proxima_acao: '', dores: [], objecoes: [], desejo: '' }, total_conversas: 0 };
};
// Default: primeira sessão que TEM conversa (sessões criadas pela sync do
// CRM vêm sem historico e são as mais recentes — não podem ser o default).
const [sel, setSel] = useState(() => {
const f = sessions.find((s) => validMsgs(s.historico).length > 0) || sessions[0];
return f && f.lead_id;
});
const [q, setQ] = useState('');
const [draft, setDraft] = useState('');
const [hist, setHist] = useState({});
// Abaixo de xl (1280px) o painel de info virou um drawer sobreposto com fundo
// escurecido (fix de hoje pro bug "informações do lead não abre"). Nessa faixa
// ele NÃO pode nascer aberto por padrão -- isso bloquearia a lista inteira atrás
// de um fundo escuro assim que a tela carrega, com um lead qualquer já
// pré-selecionado (achado testando ao vivo: 1o clique na lista ficava preso,
// interceptado pelo backdrop). Em xl+ é inline (não bloqueia nada) e continua
// aberto por padrão, do jeito que sempre foi.
const xlNow = () => (typeof window !== 'undefined' ? window.innerWidth >= 1280 : true);
const [showInfo, setShowInfo] = useState(xlNow);
const [editOpen, setEditOpen] = useState(false);
const [overrides, setOverrides] = useState({});
const [mobShowThread, setMobShowThread] = useState(false); // master-detail no mobile
const bottom = useRef(null);
// ── Assumir/retomar (modo manual). manualOv guarda o override local por lead
// pra UI refletir na hora; o valor real vem de isPaused (leads.modo_manual
// carregado do banco). pausing/sending evitam clique duplo; sendErr mostra
// falha REAL de entrega em vez de fingir que enviou.
const [manualOv, setManualOv] = useState({});
const [pausing, setPausing] = useState(false);
const [sending, setSending] = useState(false);
const [sendErr, setSendErr] = useState(null);
const cur = sessions.find((s) => s.lead_id === sel);
const baseLead = infoFor(cur);
const lead = overrides[sel] ? { ...baseLead, ...overrides[sel] } : baseLead;
// Metadados por agente: a Nanda só tem 'vendas', então isAltAgent fica
// permanentemente false e AgMeta indefinido (o JSX abaixo os usa em vários
// pontos; mantidos pra um eventual 2º agente).
const AGENTE_META = {};
const agente = (cur && cur.agente) || 'vendas';
const isAltAgent = false;
const AgMeta = AGENTE_META[agente];
const messages = validMsgs((hist[sel]) || (cur && cur.historico));
useEffect(() => { if (bottom.current) bottom.current.scrollTop = bottom.current.scrollHeight; }, [sel, messages.length]);
useEffect(() => { setShowInfo(xlNow()); }, [sel]);
// Estado "assumido" (IA pausada) do lead atual: override local vence, senão o
// valor carregado do banco (isPaused <- leads.modo_manual).
const isPaused = (sel in manualOv) ? manualOv[sel] : !!(cur && cur.pausado);
// Assumir (pausa a Nanda) / Retomar (devolve pra Nanda). Chama o backend e
// reflete na hora; em falha, mostra o erro e NÃO muda o estado visual.
const toggleManual = async () => {
if (!lead || pausing) return;
const next = !isPaused;
setPausing(true); setSendErr(null);
try {
await D.setManual(lead, next);
setManualOv((o) => ({ ...o, [sel]: next }));
} catch (e) {
setSendErr((next ? 'Falha ao assumir a conversa: ' : 'Falha ao retomar: ') + ((e && e.message) || 'erro'));
} finally { setPausing(false); }
};
const send = async () => {
const text = draft.trim();
if (!text || !lead || sending) return;
setSendErr(null); setSending(true);
const optimistic = { fromMe: true, text, type: 'text', timestamp: new Date().toISOString() };
setHist((h) => ({ ...h, [sel]: [ ...((h[sel]) || messages), optimistic ] }));
setDraft('');
// Ao mandar manual, ASSUME a conversa (pausa a Nanda) se ainda não estiver
// assumida.
if (!isAltAgent && !isPaused) {
try { await D.setManual(lead, true); setManualOv((o) => ({ ...o, [sel]: true })); }
catch (e) { console.warn('[painel] auto-pausar ao assumir falhou:', (e && e.message) || e); }
}
// Envio real. Se o backend falhar (ex.: lead fora da janela de 24h / Direct
// Send bloqueado), desfaz o balão otimista, devolve o texto e AVISA — nada
// de fingir que enviou.
try {
const res = await D.sendMessage(lead, text, agente);
if (res && res.success === false) throw new Error(res.error || 'falha no envio');
} catch (e) {
const hint = ' — se o lead está há mais de 24h sem responder, o WhatsApp exige um template aprovado.';
setHist((h) => ({ ...h, [sel]: ((h[sel]) || []).filter((m) => m !== optimistic) }));
setDraft(text);
setSendErr('Não foi possível entregar. ' + ((e && e.message) || '') + hint);
} finally { setSending(false); }
};
// "Chats Ativos" = sessões com pelo menos 1 mensagem real. As sessions que a
// sync do CRM cria só com cadastro (sem historico) não são chats — escondê-las
// aqui não esconde dado bom: os 22+ leads que responderam TÊM historico e ficam.
const list = sessions.filter((s) => {
const l = leadById[s.lead_id];
// Sessão sem lead correspondente: continua escondida (comportamento atual, sem regressão).
if (!l) return false;
if (effFilter !== 'todos' && (s.agente || 'vendas') !== effFilter) return false;
const nome = (l && l.nome) || s.nome || s.telefone || '';
if (!nome.toLowerCase().includes(q.toLowerCase())) return false;
return validMsgs(hist[s.lead_id] || s.historico).length > 0;
});
return (
{/* Esquerda: lista (full no mobile; some quando uma conversa está aberta) */}
Chats Ativos
{showFilter ? (
{agentTabs.map(([k, lb]) => (
))}
) : null}
{list.map((s) => {
const l = infoFor(s); const msgs = validMsgs(hist[s.lead_id] || s.historico);
const rowMeta = null; // badge de agente alternativo: sem uso, só 'vendas' aqui
// last pode não existir (sessão sem conversa) — o render abaixo tolera.
const last = msgs[msgs.length - 1]; const on = s.lead_id === sel;
return (
);
})}
{/* Centro: thread (some no mobile enquanto a lista está aberta) */}
A resposta sai pelo número de {AgMeta.label}{cur && cur.telefone ? ' (+' + cur.telefone + ')' : ''} — nunca pelo de vendas.
)}
setDraft(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} disabled={sending}
placeholder={isAltAgent ? ('Responder pelo número de ' + AgMeta.label + '…') : (isPaused ? 'Você assumiu: escreva pro cliente (a ' + AG + ' está pausada)' : (AG + ' está respondendo automaticamente; enviar assume a conversa'))} 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 disabled:opacity-60" />
> :
Selecione uma conversa
}
{/* Direita: info do lead — inline a partir de xl (1280px); abaixo disso vira um drawer
sobreposto com fundo escurecido. Antes o painel só existia com "xl:flex" (sem "flex"
de base), então em QUALQUER tela abaixo de 1280px o clique no botão de toggle (I.Info)
não tinha nenhum efeito visível — bug reportado como "informações do lead não abre nada",
confirmado ao vivo em 1024x768 e em 390x844 (mobile): painel montado, display:none. */}
{lead && showInfo && <>