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:
@@ -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<string, ViewKey> = {
|
||||
'/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<ViewKey, string> = {
|
||||
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 <ContratosView />;
|
||||
case 'plugins': return <PluginsView />;
|
||||
case 'rx-radiografias': return <RXPlugin />;
|
||||
case 'wa-inbox': return <WaInboxView />;
|
||||
case 'wa-secretaria': return <WaSecretariaView />;
|
||||
case 'salas': return <SalasPlugin />;
|
||||
case 'minhas-salas': return <SalasPlugin view="minhas" />;
|
||||
case 'alugar-salas': return <SalasPlugin view="alugar" />;
|
||||
|
||||
@@ -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<SidebarProps> = ({ 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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user