b031117925
- PluginsView: registra o plugin 'newwhats' (config só no catálogo, exclusivo do
superadmin via view 'plugins' — já gateada em isViewAllowed).
- pluginRegistry: 'newwhats' em ALWAYS_ON (ativação por plugin é local/localStorage
e não propaga; ALWAYS_ON garante Inbox/Secretária para TODOS os usuários).
- App.tsx: views wa-inbox/wa-secretaria (ViewKey, rotas, render); gating
isPluginActive('newwhats') libera a qualquer usuário.
- Sidebar: itens WHATSAPP + SECRETÁRIA IA quando o plugin está ativo.
- Novas views consomem o proxy /api/nw/v1/* (motor ext-api). Estágio 1: funcional
(lista/thread/envio; ask da secretária), visual aproximado do motor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
7.2 KiB
TypeScript
136 lines
7.2 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();
|
|
}
|
|
|
|
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 }
|
|
|
|
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 endRef = useRef<HTMLDivElement>(null);
|
|
|
|
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([]);
|
|
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' }}>
|
|
<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>
|
|
</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>
|
|
);
|
|
};
|