diff --git a/frontend/App.tsx b/frontend/App.tsx index 1dbf109..9075657 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -12,6 +12,8 @@ import { useAppVersion } from './buildInfo.ts'; import { Dashboard } from './views/Dashboard.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 { PatientsView } from './views/PatientsView.tsx'; import { AgendaView } from './views/AgendaView.tsx'; @@ -86,6 +88,8 @@ export type ViewKey = | 'contratos' | 'plugins' | 'rx-radiografias' + | 'wa-inbox' + | 'wa-secretaria' | 'salas' | 'minhas-salas' | 'alugar-salas' @@ -144,6 +148,8 @@ const ROUTE_MAP: Record = { '/contratos': 'contratos', '/plugins': 'plugins', '/rx-radiografias': 'rx-radiografias', + '/wa-inbox': 'wa-inbox', + '/wa-secretaria': 'wa-secretaria', '/salas': 'salas', '/minhas-salas': 'minhas-salas', '/alugar-salas': 'alugar-salas', @@ -207,6 +213,8 @@ const VIEW_TO_ROUTE: Record = { contratos: '/contratos', plugins: '/plugins', 'rx-radiografias': '/rx-radiografias', + 'wa-inbox': '/wa-inbox', + 'wa-secretaria': '/wa-secretaria', salas: '/salas', 'minhas-salas': '/minhas-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 // 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'); + // 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. 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. @@ -506,6 +517,8 @@ const App: React.FC = () => { case 'contratos': return ; case 'plugins': return ; case 'rx-radiografias': return ; + case 'wa-inbox': return ; + case 'wa-secretaria': return ; case 'salas': return ; case 'minhas-salas': return ; case 'alugar-salas': return ; diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index 7ed2549..5ce0e90 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useConfirm } from '../contexts/ConfirmContext.tsx'; import { 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'; import { ToothIcon } from './ToothIcon.tsx'; import { HybridBackend } from '../services/backend.ts'; @@ -37,6 +37,12 @@ export const Sidebar: React.FC = ({ activeTab, setActiveTab }) => 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 // 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 diff --git a/frontend/views/PluginsView.tsx b/frontend/views/PluginsView.tsx index bed4ff9..107e272 100644 --- a/frontend/views/PluginsView.tsx +++ b/frontend/views/PluginsView.tsx @@ -29,6 +29,31 @@ interface 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', name: 'RX ScoreOdonto', diff --git a/frontend/views/WaInboxView.tsx b/frontend/views/WaInboxView.tsx new file mode 100644 index 0000000..f7fa80d --- /dev/null +++ b/frontend/views/WaInboxView.tsx @@ -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([]); + const [session, setSession] = useState(''); + const [chats, setChats] = useState([]); + const [active, setActive] = useState(null); + const [msgs, setMsgs] = useState([]); + const [text, setText] = useState(''); + const [err, setErr] = useState(null); + const [loading, setLoading] = useState(false); + const endRef = useRef(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 ( +
+ + {err &&
Erro: {err}. Verifique a configuração do plugin (super-admin) e o pareamento com o motor.
} +
+ {/* Lista de conversas */} +
+
+ +
+
+ {loading &&
Carregando…
} + {chats.map((c) => ( +
openChat(c)} + style={{ padding: '10px 12px', cursor: 'pointer', borderBottom: '1px solid #f3f4f6', background: active?.id === c.id ? '#f0fdf4' : 'transparent' }}> +
+ {c.displayName || c.phone || c.remote_jid} + {!!c.unread_count && {c.unread_count}} +
+
{c.last_message_text || ''}
+
+ ))} + {!loading && !chats.length &&
Nenhuma conversa.
} +
+
+ {/* Thread */} +
+ {!active ? ( +
Selecione uma conversa
+ ) : ( + <> +
+ {active.displayName || active.phone || active.remote_jid} +
+
+ {msgs.map((m) => { + const out = m.from_me === 1 || m.direction === 'out'; + return ( +
+
+ {m.text || [{m.type || 'mídia'}]} +
+
+ ); + })} +
+
+
+ setText(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} + placeholder="Mensagem" style={{ flex: 1, padding: '8px 12px', borderRadius: 20, border: '1px solid #d1d5db' }} /> + +
+ + )} +
+
+
+ ); +}; diff --git a/frontend/views/WaSecretariaView.tsx b/frontend/views/WaSecretariaView.tsx new file mode 100644 index 0000000..02f8632 --- /dev/null +++ b/frontend/views/WaSecretariaView.tsx @@ -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>([]); + const [text, setText] = useState(''); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const chatId = useRef(`painel-${Date.now()}`); + const endRef = useRef(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 ( +
+ + {err &&
Erro: {err}
} +
+
+ {!turns.length &&
Faça uma pergunta para testar a Secretária (ex.: "Quais procedimentos vocês fazem e os valores?").
} + {turns.map((t, i) => ( +
+
+
{t.q}
+
+
+
{t.a}
+
+
+ ))} + {busy &&
Secretária digitando…
} +
+
+
+ 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' }} /> + +
+
+
+ ); +}; diff --git a/frontend/views/plugins/pluginRegistry.ts b/frontend/views/plugins/pluginRegistry.ts index 5852892..0971888 100644 --- a/frontend/views/plugins/pluginRegistry.ts +++ b/frontend/views/plugins/pluginRegistry.ts @@ -13,7 +13,11 @@ export const getActivePlugins = (): string[] => { }; // 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);