Files
scoreodonto.com/frontend/views/WaInboxView.tsx
VPS 4 Builder 3042ddca38 feat(newwhats): frontend do plugin (Inbox/Sessions/Secretária) em tela cheia
- /wa-inbox, /wa-sessions e /wa-secretaria rodam sem a sidebar do scoreodonto
  (isStandaloneView); cada tela tem seu "Voltar" (Secretária ganhou botão na ThinNav).
- SessionsView: banner "Você já possui acesso ao WhatsApp" quando há sessão ativa.
- avatares: usa a URL do CDN (contactAvatarUrl/instance.avatar) direto; Avatar
  exibe sem depender de version; self-heal no onError re-busca a foto atual.
- deps: framer-motion, immer. Dev override com HMR (docker-compose.dev.yml).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:54:24 +02:00

232 lines
12 KiB
TypeScript

// WaInboxView — Inbox WhatsApp do scoreodonto, consumindo o motor NewWhats via
// proxy do backend (/api/nw/v1/* → motor /api/ext/v1/*). Estágio 1: lista de
// conversas + thread + envio. Visual aproximado do motor (WhatsApp-like).
import React, { useEffect, useRef, useState } from 'react';
import { PageHeader } from '../components/PageHeader.tsx';
const API = (import.meta as any).env?.VITE_API_URL || '/api';
async function nw(path: string, opts: RequestInit = {}) {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
const res = await fetch(`${API}/nw/v1${path}`, {
...opts,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts.headers || {}),
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`.slice(0, 200));
return res.json();
}
// URL do túnel WS (/api/nw/v1/stream → motor). Deriva o host do API (relativo ou
// absoluto) e troca http(s)→ws(s). O JWT vai no query porque o browser não envia
// Authorization no handshake de WebSocket.
function streamUrl(token: string) {
const base = API.startsWith('http') ? API : `${window.location.origin}${API}`;
const u = new URL(`${base}/nw/v1/stream`);
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
u.searchParams.set('token', token);
return u.toString();
}
interface Chat { id: string | number; remote_jid?: string; displayName?: string; phone?: string; last_message_text?: string | null; unread_count?: number }
interface Msg { id: string | number; text?: string; direction?: 'in' | 'out'; from_me?: number; timestamp?: number; type?: string }
// Normaliza a mensagem do stream (shape do motor) para o Msg do inbox.
function normalizeWsMsg(m: any): Msg {
return {
id: m?.id ?? `ws-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
text: m?.body ?? m?.text,
from_me: (m?.fromMe === true || m?.fromMe === 1) ? 1 : 0,
direction: m?.fromMe ? 'out' : 'in',
type: m?.type,
timestamp: m?.timestamp,
};
}
// Atualiza a lista de conversas com uma mensagem nova: preview, badge de unread
// (só quando não é a conversa aberta e não é enviada por nós) e sobe pro topo.
function bumpChat(list: Chat[], chatId: any, m: Msg, countUnread: boolean): Chat[] {
const i = list.findIndex((c) => String(c.id) === String(chatId));
if (i === -1) return list; // conversa ainda não carregada — aparece no próximo refresh
const updated: Chat = { ...list[i], last_message_text: m.text || `[${m.type || 'mídia'}]` };
if (countUnread && m.from_me !== 1) updated.unread_count = (updated.unread_count || 0) + 1;
return [updated, ...list.filter((_, idx) => idx !== i)];
}
export const WaInboxView: React.FC = () => {
const [sessions, setSessions] = useState<any[]>([]);
const [session, setSession] = useState<string>('');
const [chats, setChats] = useState<Chat[]>([]);
const [active, setActive] = useState<Chat | null>(null);
const [msgs, setMsgs] = useState<Msg[]>([]);
const [text, setText] = useState('');
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [wsUp, setWsUp] = useState(false);
const [qr, setQr] = useState<string | null>(null);
const endRef = useRef<HTMLDivElement>(null);
// Ref com a conversa aberta para o handler do WS ler sempre o valor atual
// (evita closure velha dentro do onmessage).
const activeRef = useRef<Chat | null>(null);
useEffect(() => { activeRef.current = active; }, [active]);
// Tempo real: conecta ao túnel WS e reflete eventos do motor sem refetch.
useEffect(() => {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
if (!token) return;
let ws: WebSocket | null = null;
let retry: ReturnType<typeof setTimeout> | null = null;
let closed = false;
const connect = () => {
try { ws = new WebSocket(streamUrl(token)); } catch { return; }
ws.onopen = () => setWsUp(true);
ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } };
ws.onclose = () => {
setWsUp(false);
if (!closed) retry = setTimeout(connect, 3000); // reconexão simples
};
ws.onmessage = (ev) => {
let env: any;
try { env = JSON.parse(ev.data); } catch { return; }
const { event, data } = env || {};
if (event === 'message.new') {
const norm = normalizeWsMsg(data?.message);
const isActive = !!activeRef.current && String(data?.chatId) === String(activeRef.current.id);
if (isActive) {
setMsgs((p) => p.some((x) => String(x.id) === String(norm.id)) ? p : [...p, norm]);
setTimeout(() => endRef.current?.scrollIntoView(), 50);
}
setChats((prev) => bumpChat(prev, data?.chatId, norm, !isActive));
} else if (event === 'session.status') {
setSessions((prev) => prev.map((s) =>
String(s.id || s.instance) === String(data?.instanceId) ? { ...s, status: data?.status } : s));
if (data?.status === 'connected') setQr(null);
} else if (event === 'session.qr') {
setQr(data?.qrBase64 || null);
}
};
};
connect();
return () => {
closed = true;
if (retry) clearTimeout(retry);
try { ws?.close(); } catch { /* ignore */ }
};
}, []);
useEffect(() => {
nw('/sessions').then((s) => {
const list = Array.isArray(s) ? s : [];
setSessions(list);
const conn = list.find((i: any) => String(i.status).toLowerCase() === 'connected') || list[0];
if (conn) setSession(conn.id || conn.instance);
}).catch((e) => setErr(String(e.message)));
}, []);
useEffect(() => {
if (!session) return;
setLoading(true);
nw(`/inbox?session=${encodeURIComponent(session)}&limit=50`)
.then((c) => setChats(Array.isArray(c) ? c : (c.chats ?? [])))
.catch((e) => setErr(String(e.message)))
.finally(() => setLoading(false));
}, [session]);
const openChat = async (c: Chat) => {
setActive(c); setMsgs([]);
setChats((prev) => prev.map((x) => String(x.id) === String(c.id) ? { ...x, unread_count: 0 } : x));
try {
const m = await nw(`/inbox/${encodeURIComponent(String(c.id))}/messages?limit=50`);
setMsgs(Array.isArray(m) ? m : (m.messages ?? []));
setTimeout(() => endRef.current?.scrollIntoView(), 50);
} catch (e: any) { setErr(String(e.message)); }
};
const send = async () => {
if (!active || !text.trim()) return;
const body = text.trim(); setText('');
try {
await nw(`/inbox/${encodeURIComponent(String(active.id))}/send`, { method: 'POST', body: JSON.stringify({ text: body }) });
setMsgs((p) => [...p, { id: `tmp-${Date.now()}`, text: body, from_me: 1, direction: 'out', timestamp: Date.now() }]);
setTimeout(() => endRef.current?.scrollIntoView(), 50);
} catch (e: any) { setErr(String(e.message)); }
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<PageHeader title="WhatsApp" description="Inbox integrado ao motor NewWhats" />
{err && <div style={{ background: '#fee2e2', color: '#991b1b', padding: '8px 12px', fontSize: 13 }}>Erro: {err}. Verifique a configuração do plugin (super-admin) e o pareamento com o motor.</div>}
<div style={{ display: 'flex', flex: 1, minHeight: 0, border: '1px solid #e5e7eb', borderRadius: 8, overflow: 'hidden', background: '#fff' }}>
{/* Lista de conversas */}
<div style={{ width: 320, borderRight: '1px solid #e5e7eb', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: 8, borderBottom: '1px solid #e5e7eb' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, fontSize: 11, color: wsUp ? '#16a34a' : '#9ca3af' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: wsUp ? '#22c55e' : '#d1d5db', display: 'inline-block' }} />
{wsUp ? 'Tempo real ativo' : 'Conectando tempo real…'}
</div>
<select value={session} onChange={(e) => setSession(e.target.value)} style={{ width: '100%', padding: 6, fontSize: 13 }}>
<option value="">Selecione a sessão</option>
{sessions.map((s) => <option key={s.id || s.instance} value={s.id || s.instance}>{(s.nome || s.name || s.phone || s.id)} ({s.status})</option>)}
</select>
{qr && (
<div style={{ marginTop: 8, textAlign: 'center', background: '#fff', border: '1px solid #e5e7eb', borderRadius: 8, padding: 8 }}>
<div style={{ fontSize: 12, color: '#6b7280', marginBottom: 6 }}>Escaneie para conectar</div>
<img src={qr.startsWith('data:') ? qr : `data:image/png;base64,${qr}`} alt="QR Code" style={{ width: '100%', maxWidth: 220 }} />
</div>
)}
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{loading && <div style={{ padding: 12, color: '#6b7280', fontSize: 13 }}>Carregando</div>}
{chats.map((c) => (
<div key={c.id} onClick={() => openChat(c)}
style={{ padding: '10px 12px', cursor: 'pointer', borderBottom: '1px solid #f3f4f6', background: active?.id === c.id ? '#f0fdf4' : 'transparent' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<strong style={{ fontSize: 14 }}>{c.displayName || c.phone || c.remote_jid}</strong>
{!!c.unread_count && <span style={{ background: '#22c55e', color: '#fff', borderRadius: 10, fontSize: 11, padding: '0 6px' }}>{c.unread_count}</span>}
</div>
<div style={{ fontSize: 12, color: '#6b7280', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{c.last_message_text || ''}</div>
</div>
))}
{!loading && !chats.length && <div style={{ padding: 12, color: '#9ca3af', fontSize: 13 }}>Nenhuma conversa.</div>}
</div>
</div>
{/* Thread */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', background: '#efeae2' }}>
{!active ? (
<div style={{ margin: 'auto', color: '#6b7280' }}>Selecione uma conversa</div>
) : (
<>
<div style={{ padding: '10px 14px', background: '#f0f2f5', borderBottom: '1px solid #e5e7eb', fontWeight: 600 }}>
{active.displayName || active.phone || active.remote_jid}
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 14 }}>
{msgs.map((m) => {
const out = m.from_me === 1 || m.direction === 'out';
return (
<div key={m.id} style={{ display: 'flex', justifyContent: out ? 'flex-end' : 'flex-start', marginBottom: 6 }}>
<div style={{ maxWidth: '70%', padding: '6px 10px', borderRadius: 8, fontSize: 14, background: out ? '#d9fdd3' : '#fff', boxShadow: '0 1px 0.5px rgba(0,0,0,.13)' }}>
{m.text || <em style={{ color: '#6b7280' }}>[{m.type || 'mídia'}]</em>}
</div>
</div>
);
})}
<div ref={endRef} />
</div>
<div style={{ display: 'flex', gap: 8, padding: 10, background: '#f0f2f5' }}>
<input value={text} onChange={(e) => setText(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()}
placeholder="Mensagem" style={{ flex: 1, padding: '8px 12px', borderRadius: 20, border: '1px solid #d1d5db' }} />
<button onClick={send} style={{ background: '#22c55e', color: '#fff', border: 'none', borderRadius: 20, padding: '8px 16px', cursor: 'pointer' }}>Enviar</button>
</div>
</>
)}
</div>
</div>
</div>
);
};