feat(frontend): plugin NewWhats — Inbox + Secretária (gating por papel)

- 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>
This commit is contained in:
VPS 4 Builder
2026-06-28 17:57:46 +02:00
parent b21fb0f159
commit b031117925
6 changed files with 252 additions and 2 deletions
+13
View File
@@ -12,6 +12,8 @@ import { useAppVersion } from './buildInfo.ts';
import { Dashboard } from './views/Dashboard.tsx'; import { Dashboard } from './views/Dashboard.tsx';
import { LeadsView } from './views/LeadsView.tsx'; import { LeadsView } from './views/LeadsView.tsx';
import { WaInboxView } from './views/WaInboxView.tsx';
import { WaSecretariaView } from './views/WaSecretariaView.tsx';
import { PublicContactForm } from './views/PublicContactForm.tsx'; import { PublicContactForm } from './views/PublicContactForm.tsx';
import { PatientsView } from './views/PatientsView.tsx'; import { PatientsView } from './views/PatientsView.tsx';
import { AgendaView } from './views/AgendaView.tsx'; import { AgendaView } from './views/AgendaView.tsx';
@@ -86,6 +88,8 @@ export type ViewKey =
| 'contratos' | 'contratos'
| 'plugins' | 'plugins'
| 'rx-radiografias' | 'rx-radiografias'
| 'wa-inbox'
| 'wa-secretaria'
| 'salas' | 'salas'
| 'minhas-salas' | 'minhas-salas'
| 'alugar-salas' | 'alugar-salas'
@@ -144,6 +148,8 @@ const ROUTE_MAP: Record<string, ViewKey> = {
'/contratos': 'contratos', '/contratos': 'contratos',
'/plugins': 'plugins', '/plugins': 'plugins',
'/rx-radiografias': 'rx-radiografias', '/rx-radiografias': 'rx-radiografias',
'/wa-inbox': 'wa-inbox',
'/wa-secretaria': 'wa-secretaria',
'/salas': 'salas', '/salas': 'salas',
'/minhas-salas': 'minhas-salas', '/minhas-salas': 'minhas-salas',
'/alugar-salas': 'alugar-salas', '/alugar-salas': 'alugar-salas',
@@ -207,6 +213,8 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
contratos: '/contratos', contratos: '/contratos',
plugins: '/plugins', plugins: '/plugins',
'rx-radiografias': '/rx-radiografias', 'rx-radiografias': '/rx-radiografias',
'wa-inbox': '/wa-inbox',
'wa-secretaria': '/wa-secretaria',
salas: '/salas', salas: '/salas',
'minhas-salas': '/minhas-salas', 'minhas-salas': '/minhas-salas',
'alugar-salas': '/alugar-salas', 'alugar-salas': '/alugar-salas',
@@ -315,6 +323,9 @@ const App: React.FC = () => {
// Locação de Salas: capability PESSOAL (add-on por conta), liberada a qualquer // Locação de Salas: capability PESSOAL (add-on por conta), liberada a qualquer
// usuário com o plugin ativo na conta — independe de ter clínica/cargo. // usuário com o plugin ativo na conta — independe de ter clínica/cargo.
if (view === 'salas' || view === 'minhas-salas' || view === 'alugar-salas') return isPluginActive('locacao-salas'); if (view === 'salas' || view === 'minhas-salas' || view === 'alugar-salas') return isPluginActive('locacao-salas');
// NewWhats: Inbox e Secretária liberadas a qualquer usuário com o plugin ativo
// (a CONFIG do plugin é exclusiva do superadmin via view 'plugins').
if (view === 'wa-inbox' || view === 'wa-secretaria') return isPluginActive('newwhats');
// Contexto de SALA: isola da clínica — só painel da sala/salas/conta/notificações. // Contexto de SALA: isola da clínica — só painel da sala/salas/conta/notificações.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return ['sala-home', 'configuracoes', 'notificacoes', 'clinicas'].includes(view); if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return ['sala-home', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
// Contexto de LABORATÓRIO: área do protético — painel do lab + bancada/conta/notificações. // Contexto de LABORATÓRIO: área do protético — painel do lab + bancada/conta/notificações.
@@ -506,6 +517,8 @@ const App: React.FC = () => {
case 'contratos': return <ContratosView />; case 'contratos': return <ContratosView />;
case 'plugins': return <PluginsView />; case 'plugins': return <PluginsView />;
case 'rx-radiografias': return <RXPlugin />; case 'rx-radiografias': return <RXPlugin />;
case 'wa-inbox': return <WaInboxView />;
case 'wa-secretaria': return <WaSecretariaView />;
case 'salas': return <SalasPlugin />; case 'salas': return <SalasPlugin />;
case 'minhas-salas': return <SalasPlugin view="minhas" />; case 'minhas-salas': return <SalasPlugin view="minhas" />;
case 'alugar-salas': return <SalasPlugin view="alugar" />; case 'alugar-salas': return <SalasPlugin view="alugar" />;
+7 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { useConfirm } from '../contexts/ConfirmContext.tsx'; import { useConfirm } from '../contexts/ConfirmContext.tsx';
import { import {
Users, Calendar as CalendarIcon, LayoutDashboard, Users, Calendar as CalendarIcon, LayoutDashboard,
DollarSign, RefreshCw, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText, Puzzle, ScanLine, Monitor, UsersRound, GraduationCap, UserSearch, Briefcase, TrendingUp, Eye, FlaskConical, DoorOpen, Sparkles, FileSignature, Percent, ShieldAlert, ChevronDown DollarSign, RefreshCw, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText, Puzzle, ScanLine, Monitor, UsersRound, GraduationCap, UserSearch, Briefcase, TrendingUp, Eye, FlaskConical, DoorOpen, Sparkles, FileSignature, Percent, ShieldAlert, ChevronDown, MessageCircle, Bot
} from 'lucide-react'; } from 'lucide-react';
import { ToothIcon } from './ToothIcon.tsx'; import { ToothIcon } from './ToothIcon.tsx';
import { HybridBackend } from '../services/backend.ts'; import { HybridBackend } from '../services/backend.ts';
@@ -37,6 +37,12 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
pluginMenuItems.push({ id: 'rx-config', label: 'DISPOSITIVOS RX', icon: Monitor, color: '#7c3aed' }); pluginMenuItems.push({ id: 'rx-config', label: 'DISPOSITIVOS RX', icon: Monitor, color: '#7c3aed' });
} }
} }
// NewWhats: Inbox + Secretária IA — para TODOS os usuários quando o plugin está
// ativo (a CONFIG do plugin fica no catálogo PLUGINS, exclusivo do superadmin).
if (activePluginIds.includes('newwhats')) {
pluginMenuItems.push({ id: 'wa-inbox', label: 'WHATSAPP', icon: MessageCircle, color: '#16a34a' });
pluginMenuItems.push({ id: 'wa-secretaria', label: 'SECRETÁRIA IA', icon: Bot, color: '#2563eb' });
}
// Locação de Salas: menus de LOCADOR (administra as próprias salas) e LOCATÁRIO (aluga de // Locação de Salas: menus de LOCADOR (administra as próprias salas) e LOCATÁRIO (aluga de
// terceiros). São decisões de NEGÓCIO do titular — não cabem a funcionário (subordinado) nem // terceiros). São decisões de NEGÓCIO do titular — não cabem a funcionário (subordinado) nem
// a paciente. Funcionário, quando muito, opera a AGENDA da sala (workspace tipo='sala'), não // a paciente. Funcionário, quando muito, opera a AGENDA da sala (workspace tipo='sala'), não
+25
View File
@@ -29,6 +29,31 @@ interface PluginDef {
} }
const PLUGINS: PluginDef[] = [ const PLUGINS: PluginDef[] = [
{
id: 'newwhats',
name: 'NewWhats — WhatsApp & Secretária IA',
tagline: 'Inbox WhatsApp + Atendente virtual',
description: 'Integração com o motor NewWhats: Inbox de WhatsApp em tempo real e Secretária IA. Inbox e Secretária ficam disponíveis para todos os usuários; esta configuração é exclusiva do super-admin.',
version: '1.0.0',
author: 'ScoreOdonto',
category: 'COMUNICAÇÃO',
color: '#16a34a',
sidebarItems: [
{ id: 'wa-inbox', label: 'WHATSAPP' },
{ id: 'wa-secretaria', label: 'SECRETÁRIA IA' },
],
features: [
'Inbox WhatsApp (conversas, mensagens, envio)',
'Secretária IA (atendente virtual do motor)',
'Base de conhecimento da clínica sincronizada com o motor',
],
configFields: [
{ key: 'NEWWHATS_URL', label: 'URL DO MOTOR', type: 'text', placeholder: 'https://newwhats.clube67.com', defaultValue: '' },
{ key: 'NEWWHATS_INTEGRATION_KEY', label: 'CHAVE DE INTEGRAÇÃO', type: 'password', placeholder: 'nw_...', defaultValue: '' },
{ key: 'NEWWHATS_WEBHOOK_SECRET', label: 'SEGREDO DO WEBHOOK', type: 'password', placeholder: '(HMAC)', defaultValue: '' },
{ key: 'NEWWHATS_CLINICA_ID', label: 'CLÍNICA (ID)', type: 'text', placeholder: 'c_...', defaultValue: '' },
],
},
{ {
id: 'rx-scoreodonto', id: 'rx-scoreodonto',
name: 'RX ScoreOdonto', name: 'RX ScoreOdonto',
+135
View File
@@ -0,0 +1,135 @@
// 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>
);
};
+67
View File
@@ -0,0 +1,67 @@
// WaSecretariaView — painel da Secretária IA (motor) no scoreodonto. Estágio 1:
// chat de teste via proxy POST /api/nw/v1/secretaria/ask. A configuração do
// cérebro/agente vive no motor (acessível ao super-admin).
import React, { useRef, useState } from 'react';
import { PageHeader } from '../components/PageHeader.tsx';
const API = (import.meta as any).env?.VITE_API_URL || '/api';
async function ask(chatId: string, message: string) {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
const res = await fetch(`${API}/nw/v1/secretaria/ask`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ chatId, message, contactName: 'Teste (painel)' }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`.slice(0, 200));
return res.json();
}
export const WaSecretariaView: React.FC = () => {
const [turns, setTurns] = useState<Array<{ q: string; a: string }>>([]);
const [text, setText] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const chatId = useRef(`painel-${Date.now()}`);
const endRef = useRef<HTMLDivElement>(null);
const send = async () => {
if (!text.trim() || busy) return;
const q = text.trim(); setText(''); setBusy(true); setErr(null);
try {
const r = await ask(chatId.current, q);
setTurns((p) => [...p, { q, a: r.reply ?? JSON.stringify(r) }]);
setTimeout(() => endRef.current?.scrollIntoView(), 50);
} catch (e: any) { setErr(String(e.message)); }
finally { setBusy(false); }
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<PageHeader title="Secretária IA" description="Atendente virtual (motor NewWhats) — painel de teste" />
{err && <div style={{ background: '#fee2e2', color: '#991b1b', padding: '8px 12px', fontSize: 13 }}>Erro: {err}</div>}
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', border: '1px solid #e5e7eb', borderRadius: 8, background: '#fff', overflow: 'hidden' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>
{!turns.length && <div style={{ color: '#9ca3af', fontSize: 14 }}>Faça uma pergunta para testar a Secretária (ex.: "Quais procedimentos vocês fazem e os valores?").</div>}
{turns.map((t, i) => (
<div key={i} style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 4 }}>
<div style={{ background: '#dbeafe', padding: '8px 12px', borderRadius: 10, fontSize: 14, maxWidth: '75%' }}>{t.q}</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
<div style={{ background: '#f3f4f6', padding: '8px 12px', borderRadius: 10, fontSize: 14, maxWidth: '75%', whiteSpace: 'pre-wrap' }}>{t.a}</div>
</div>
</div>
))}
{busy && <div style={{ color: '#6b7280', fontSize: 13 }}>Secretária digitando</div>}
<div ref={endRef} />
</div>
<div style={{ display: 'flex', gap: 8, padding: 10, borderTop: '1px solid #e5e7eb' }}>
<input value={text} onChange={(e) => setText(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()}
placeholder="Pergunte algo…" style={{ flex: 1, padding: '8px 12px', borderRadius: 8, border: '1px solid #d1d5db' }} />
<button onClick={send} disabled={busy} style={{ background: '#2563eb', color: '#fff', border: 'none', borderRadius: 8, padding: '8px 16px', cursor: 'pointer', opacity: busy ? 0.6 : 1 }}>Perguntar</button>
</div>
</div>
</div>
);
};
+5 -1
View File
@@ -13,7 +13,11 @@ export const getActivePlugins = (): string[] => {
}; };
// Plugins sempre ligados (capability de plataforma, sem opção de desativar). // Plugins sempre ligados (capability de plataforma, sem opção de desativar).
export const ALWAYS_ON_PLUGINS = ['locacao-salas']; // 'newwhats': Inbox/Secretária precisam aparecer para TODOS os usuários, mas a
// ativação por plugin é local (localStorage/navegador) e não propaga entre contas.
// Por isso fica ALWAYS_ON; a CONFIG continua exclusiva do superadmin (view 'plugins').
// (Futuro: gate por /api/nw/status quando o backend expuser "configurado".)
export const ALWAYS_ON_PLUGINS = ['locacao-salas', 'newwhats'];
export const isPluginActive = (id: string): boolean => ALWAYS_ON_PLUGINS.includes(id) || getActivePlugins().includes(id); export const isPluginActive = (id: string): boolean => ALWAYS_ON_PLUGINS.includes(id) || getActivePlugins().includes(id);