Compare commits
5 Commits
cfeac891fb
...
v1.0.38
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cac06e37c | |||
| b67fa15c55 | |||
| 1e16edf2be | |||
| 7a5f5cf0fa | |||
| 30ea2f0e10 |
@@ -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
@@ -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,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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user