Compare commits

...

5 Commits

Author SHA1 Message Date
ruicesar 8cac06e37c Merge pull request 'feat(superadmin): página Secretária IA (motivos de liga/desliga por sessão)' (#5) from feat/newwhats-satelite into main
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m0s
2026-07-06 20:14:08 +02:00
VPS 4 Builder b67fa15c55 feat(superadmin): página "Secretária IA" com motivos de liga/desliga por sessão
Nova aba no super admin (/superadmin/secretaria): tabela com quem ligou/desligou a
Secretária de cada número, quando e o MOTIVO do desligamento. Endpoint
/api/superadmin/secretaria-desligamentos (superadminGuard) busca o log global no
motor (resolve um e-mail de dono pareado; log é global). Item no Sidebar + rotas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:02:39 +02:00
ruicesar 1e16edf2be Merge pull request 'feat/fix: dashboard scope + agenda UI + newwhats (encaixe, session-power, papel)' (#4) from feat/newwhats-satelite into main
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m0s
2026-07-06 19:42:57 +02:00
ruicesar 7a5f5cf0fa Merge pull request 'fix(newwhats): proxy.js/server.js do auto-provisionamento (corrige 502 em prod)' (#3) from fix/newwhats-proxy-exports into main
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 54s
2026-07-06 16:35:08 +02:00
ruicesar 30ea2f0e10 Merge pull request 'feat(secretaria): agente/cérebro por número na aba Números' (#2) from feat/newwhats-satelite into main
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 52s
2026-07-06 16:07:07 +02:00
4 changed files with 118 additions and 6 deletions
+21
View File
@@ -9438,6 +9438,27 @@ app.get('/api/superadmin/login-log', superadminGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// Super admin: motivos de LIGAR/DESLIGAR a Secretária por sessão (histórico global,
// vem do motor — sec_session_power_log). Requer integração newwhats configurada.
app.get('/api/superadmin/secretaria-desligamentos', superadminGuard, async (req, res) => {
try {
const { getConfig } = await import('./newwhats/config.js');
const cfg = getConfig();
if (!cfg.motorUrl || !cfg.integrationKey) return res.json({ configurado: false, log: [] });
// O log é GLOBAL no motor; qualquer conta de dono pareada serve para autenticar.
const { rows } = await pool.query(`SELECT u.email FROM usuarios u JOIN vinculos v ON v.usuario_id = u.id WHERE v.role IN ('donoclinica','donoconsultorio') ORDER BY u.email LIMIT 1`);
const email = rows[0]?.email;
if (!email) return res.json({ configurado: true, log: [] });
const limit = Math.min(500, Math.max(1, parseInt(String(req.query.limit || '150'), 10)));
const r = await fetch(`${cfg.motorUrl}/api/ext/v1/secretaria/session-power/log?limit=${limit}`, {
headers: { 'x-nw-key': cfg.integrationKey, 'x-nw-email': email },
});
if (!r.ok) return res.status(502).json({ error: 'Motor indisponível', status: r.status });
const log = await r.json();
res.json({ configurado: true, log: Array.isArray(log) ? log : [] });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Defaults mesclados sob o JSON salvo: chaves novas (biomedico, add-ons de plugin) // Defaults mesclados sob o JSON salvo: chaves novas (biomedico, add-ons de plugin)
// aparecem na tabela mesmo p/ configs gravadas antes delas existirem. // aparecem na tabela mesmo p/ configs gravadas antes delas existirem.
const PRICING_DEFAULTS = { const PRICING_DEFAULTS = {
+25 -3
View File
@@ -121,6 +121,7 @@ export type ViewKey =
| 'superadmin-logs' | 'superadmin-logs'
| 'superadmin-financeiro' | 'superadmin-financeiro'
| 'superadmin-notificacoes' | 'superadmin-notificacoes'
| 'superadmin-secretaria'
| 'diretorio-profissionais'; | 'diretorio-profissionais';
// ------- URL <-> VIEW mapping ------- // ------- URL <-> VIEW mapping -------
@@ -181,6 +182,7 @@ const ROUTE_MAP: Record<string, ViewKey> = {
'/superadmin/logs': 'superadmin-logs', '/superadmin/logs': 'superadmin-logs',
'/superadmin/financeiro': 'superadmin-financeiro', '/superadmin/financeiro': 'superadmin-financeiro',
'/superadmin/notificacoes': 'superadmin-notificacoes', '/superadmin/notificacoes': 'superadmin-notificacoes',
'/superadmin/secretaria': 'superadmin-secretaria',
'/aguardando-convite': 'aguardando-convite', '/aguardando-convite': 'aguardando-convite',
'/diretorio-profissionais': 'diretorio-profissionais', '/diretorio-profissionais': 'diretorio-profissionais',
'/proteses': 'proteses', '/proteses': 'proteses',
@@ -248,6 +250,7 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
'superadmin-logs': '/superadmin/logs', 'superadmin-logs': '/superadmin/logs',
'superadmin-financeiro': '/superadmin/financeiro', 'superadmin-financeiro': '/superadmin/financeiro',
'superadmin-notificacoes': '/superadmin/notificacoes', 'superadmin-notificacoes': '/superadmin/notificacoes',
'superadmin-secretaria': '/superadmin/secretaria',
'diretorio-profissionais': '/diretorio-profissionais', 'diretorio-profissionais': '/diretorio-profissionais',
proteses: '/proteses', proteses: '/proteses',
}; };
@@ -315,6 +318,13 @@ const MobileBottomNav: React.FC<{
const App: React.FC = () => { const App: React.FC = () => {
const ver = useAppVersion(); const ver = useAppVersion();
const [isSidebarOpen, setSidebarOpen] = useState(false); const [isSidebarOpen, setSidebarOpen] = useState(false);
// Evita que a sidebar ANIME (slide-in) a cada reload no desktop: a transição só liga
// após o 1º paint, então no load ela já nasce posicionada; toggles do usuário animam.
const [uiMounted, setUiMounted] = useState(false);
useEffect(() => { setUiMounted(true); }, []);
// Gaveta de controles da Agenda no MOBILE (título/AGENDAR/ações) — comporta-se como
// a sidebar: some por padrão p/ dar o máximo de espaço ao calendário; abre pelo ⋯.
const [agendaControlsOpen, setAgendaControlsOpen] = useState(false);
// Recolher a sidebar no DESKTOP (persistente) — dá largura ao conteúdo (ex.: agenda). // Recolher a sidebar no DESKTOP (persistente) — dá largura ao conteúdo (ex.: agenda).
const [isSidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => { const [isSidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
try { return localStorage.getItem('scoreodonto:sidebar-collapsed') === '1'; } catch { return false; } try { return localStorage.getItem('scoreodonto:sidebar-collapsed') === '1'; } catch { return false; }
@@ -508,7 +518,7 @@ const App: React.FC = () => {
case 'leads': return <LeadsView />; case 'leads': return <LeadsView />;
case 'public': return <PublicContactForm />; case 'public': return <PublicContactForm />;
case 'pacientes': return <PatientsView />; case 'pacientes': return <PatientsView />;
case 'agenda': return <AgendaView onNavigate={handleNavigate} sidebarCollapsed={isSidebarCollapsed} onToggleSidebar={() => toggleSidebarCollapsed(!isSidebarCollapsed)} />; case 'agenda': return <AgendaView onNavigate={handleNavigate} sidebarCollapsed={isSidebarCollapsed} onToggleSidebar={() => toggleSidebarCollapsed(!isSidebarCollapsed)} controlsOpen={agendaControlsOpen} onCloseControls={() => setAgendaControlsOpen(false)} />;
case 'ortodontia': return <OrthoView />; case 'ortodontia': return <OrthoView />;
case 'financeiro': return <FinanceiroView />; case 'financeiro': return <FinanceiroView />;
case 'dentistas': return <DentistasView />; case 'dentistas': return <DentistasView />;
@@ -561,6 +571,7 @@ const App: React.FC = () => {
case 'superadmin-logs': return <SuperAdminView tab="logs" />; case 'superadmin-logs': return <SuperAdminView tab="logs" />;
case 'superadmin-financeiro': return <SuperAdminView tab="financeiro" />; case 'superadmin-financeiro': return <SuperAdminView tab="financeiro" />;
case 'superadmin-notificacoes': return <SuperAdminView tab="notificacoes" />; case 'superadmin-notificacoes': return <SuperAdminView tab="notificacoes" />;
case 'superadmin-secretaria': return <SuperAdminView tab="secretaria" />;
// Plugin Profissionais ativo absorve o diretório antigo (mesma rota, view nova). // Plugin Profissionais ativo absorve o diretório antigo (mesma rota, view nova).
case 'diretorio-profissionais': return isPluginActive('profissionais-marketplace') ? <ProfissionaisPlugin /> : <DiretorioProfissionaisView />; case 'diretorio-profissionais': return isPluginActive('profissionais-marketplace') ? <ProfissionaisPlugin /> : <DiretorioProfissionaisView />;
case 'login': case 'login':
@@ -599,7 +610,7 @@ const App: React.FC = () => {
></div> ></div>
)} )}
<div className={`fixed inset-y-0 left-0 z-30 transform transition-transform duration-300 ${isSidebarCollapsed ? 'md:hidden' : 'md:relative md:translate-x-0'} ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}> <div className={`fixed inset-y-0 left-0 z-30 transform ${uiMounted ? 'transition-transform duration-300' : ''} ${isSidebarCollapsed ? 'md:hidden' : 'md:relative md:translate-x-0'} ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<Sidebar <Sidebar
activeTab={currentView} activeTab={currentView}
setActiveTab={(t) => handleNavigate(t as ViewKey)} setActiveTab={(t) => handleNavigate(t as ViewKey)}
@@ -652,7 +663,18 @@ const App: React.FC = () => {
</div> </div>
<span className="font-bold text-gray-800 text-sm tracking-tight uppercase">SCOREODONTO</span> <span className="font-bold text-gray-800 text-sm tracking-tight uppercase">SCOREODONTO</span>
</div> </div>
<div className="w-8"></div> {/* ⋯ controles da Agenda (só nesta view) — abre a gaveta; senão, espaçador */}
{currentView === 'agenda' ? (
<button
onClick={() => setAgendaControlsOpen(true)}
className="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
title="Controles da agenda"
>
<MoreHorizontal size={24} />
</button>
) : (
<div className="w-8"></div>
)}
</div> </div>
</> </>
)} )}
+2 -1
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect } 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, MessageCircle, Bot, Smartphone 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, Smartphone, Power
} 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';
@@ -83,6 +83,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
{ id: 'superadmin-logs', label: 'ACESSOS', icon: Activity }, { id: 'superadmin-logs', label: 'ACESSOS', icon: Activity },
{ id: 'superadmin-financeiro', label: 'FINANCEIRO', icon: DollarSign }, { id: 'superadmin-financeiro', label: 'FINANCEIRO', icon: DollarSign },
{ id: 'superadmin-notificacoes', label: 'NOTIFICAÇÕES', icon: Bell }, { id: 'superadmin-notificacoes', label: 'NOTIFICAÇÕES', icon: Bell },
{ id: 'superadmin-secretaria', label: 'SECRETÁRIA IA', icon: Power },
{ id: 'sala-home', label: 'PAINEL DA SALA', icon: DoorOpen }, { id: 'sala-home', label: 'PAINEL DA SALA', icon: DoorOpen },
{ id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard }, { id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard },
{ id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical }, { id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical },
+70 -2
View File
@@ -5,7 +5,7 @@ import {
CheckCircle2, XCircle, Clock, Search, ChevronLeft, ChevronRight, CheckCircle2, XCircle, Clock, Search, ChevronLeft, ChevronRight,
TrendingUp, Stethoscope, FlaskConical, User, ToggleLeft, ToggleRight, TrendingUp, Stethoscope, FlaskConical, User, ToggleLeft, ToggleRight,
Save, AlertTriangle, Wifi, WifiOff, Briefcase, Trash2, Bell, Send, Save, AlertTriangle, Wifi, WifiOff, Briefcase, Trash2, Bell, Send,
MapPin, Phone, MoreVertical, X, ArrowLeft, UserX, Sparkles, DoorOpen, UserSearch MapPin, Phone, MoreVertical, X, ArrowLeft, UserX, Sparkles, DoorOpen, UserSearch, Power
} from 'lucide-react'; } from 'lucide-react';
const apiFetch = (url: string, opts: RequestInit = {}) => { const apiFetch = (url: string, opts: RequestInit = {}) => {
@@ -74,7 +74,7 @@ const KPI: React.FC<{ icon: React.ElementType; label: string; value: string | nu
); );
}; };
type SuperAdminTab = 'overview' | 'users' | 'clinicas' | 'logs' | 'financeiro' | 'notificacoes'; type SuperAdminTab = 'overview' | 'users' | 'clinicas' | 'logs' | 'financeiro' | 'notificacoes' | 'secretaria';
/* ═══════════════════════════════════════════════════ MAIN VIEW */ /* ═══════════════════════════════════════════════════ MAIN VIEW */
export const SuperAdminView: React.FC<{ tab?: SuperAdminTab }> = ({ tab = 'overview' }) => { export const SuperAdminView: React.FC<{ tab?: SuperAdminTab }> = ({ tab = 'overview' }) => {
@@ -86,6 +86,74 @@ export const SuperAdminView: React.FC<{ tab?: SuperAdminTab }> = ({ tab = 'overv
{tab === 'logs' && <TabLogs />} {tab === 'logs' && <TabLogs />}
{tab === 'financeiro' && <TabFinanceiro />} {tab === 'financeiro' && <TabFinanceiro />}
{tab === 'notificacoes' && <TabNotificacoes />} {tab === 'notificacoes' && <TabNotificacoes />}
{tab === 'secretaria' && <TabSecretaria />}
</div>
);
};
/* ═══════════════════════════════════════════════════ TAB SECRETÁRIA (liga/desliga por sessão) */
const TabSecretaria: React.FC = () => {
const [log, setLog] = useState<any[]>([]);
const [configurado, setConfigurado] = useState(true);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
setLoading(true);
apiFetch('/api/superadmin/secretaria-desligamentos')
.then(r => r.json())
.then(d => { setLog(Array.isArray(d.log) ? d.log : []); setConfigurado(d.configurado !== false); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const totalOff = log.filter(l => !l.enabled).length;
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tight flex items-center gap-2"><Power size={18} className="text-red-600" /> Secretária IA liga/desliga por sessão</h2>
<p className="text-xs text-gray-400 font-medium">Quem ligou/desligou a Secretária de cada número, e o motivo do desligamento. {totalOff > 0 && `${totalOff} desligamento(s).`}</p>
</div>
<button onClick={load} className="p-2 rounded-xl border border-gray-200 text-gray-400 hover:text-gray-700"><RefreshCw size={16} /></button>
</div>
{!configurado ? (
<div className="bg-amber-50 text-amber-700 text-sm rounded-2xl p-4 flex items-center gap-2"><AlertTriangle size={16} /> Integração com o motor da Secretária não está configurada neste ambiente.</div>
) : loading ? (
<div className="text-center text-gray-400 py-16">Carregando</div>
) : log.length === 0 ? (
<div className="text-center text-gray-400 py-16">Nenhum registro de liga/desliga ainda.</div>
) : (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
<table className="w-full text-sm min-w-[640px]">
<thead className="bg-gray-50 text-[10px] font-black text-gray-400 uppercase tracking-widest">
<tr>
<th className="text-left px-4 py-3">Ação</th>
<th className="text-left px-4 py-3">Número</th>
<th className="text-left px-4 py-3">Quem</th>
<th className="text-left px-4 py-3">Quando</th>
<th className="text-left px-4 py-3">Motivo</th>
</tr>
</thead>
<tbody>
{log.map((l, i) => (
<tr key={i} className={`border-t border-gray-50 ${!l.enabled ? 'bg-red-50/30' : ''}`}>
<td className="px-4 py-3">
{l.enabled
? <span className="inline-flex items-center gap-1 text-emerald-700 text-[11px] font-black uppercase"><ToggleRight size={14} /> Ligou</span>
: <span className="inline-flex items-center gap-1 text-red-600 text-[11px] font-black uppercase"><ToggleLeft size={14} /> Desligou</span>}
</td>
<td className="px-4 py-3 text-gray-700 font-bold whitespace-nowrap">{l.phone || l.name || l.instance_id}</td>
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{l.by || '—'}</td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{fmt(l.at)}</td>
<td className="px-4 py-3 text-gray-600">{l.reason || <span className="text-gray-300"></span>}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div> </div>
); );
}; };