Files
scoreodonto.com/frontend/App.tsx
T
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

703 lines
33 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { HybridBackend } from './services/backend.ts';
import { Sidebar } from './components/Sidebar.tsx';
import {
Menu, LayoutDashboard, Users, Calendar as CalendarIcon,
DollarSign, Activity, ClipboardList, Bell, Settings, MoreHorizontal, Eye, X, PanelLeftOpen
} from 'lucide-react';
import { ToastContainer } from './components/Toast.tsx';
import { useAppVersion } from './buildInfo.ts';
import { Dashboard } from './views/Dashboard.tsx';
import { LeadsView } from './views/LeadsView.tsx';
import { WaInboxView } from './views/WaInboxView.tsx';
import { SessionsView } from './views/newwhats/SessionsView.tsx';
import { InboxView } from './views/newwhats/InboxView.tsx';
import { SecretariaView } from './views/newwhats/SecretariaView.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';
import { OrthoView } from './views/OrthoView.tsx';
import { FinanceiroView } from './views/FinanceiroView.tsx';
import { MeusTratamentos } from './views/MeusTratamentos';
import { LancarGTO } from './views/LancarGTO';
import { LoginView } from './views/LoginView.tsx';
import { ChangePasswordModal } from './components/ChangePasswordModal.tsx';
import { LandingPage } from './views/LandingPage.tsx';
import { UpdateDbView } from './views/UpdateDbView.tsx';
import { ClinicasView } from './views/ClinicasView.tsx';
import { DentistRegisterView } from './views/DentistRegisterView.tsx';
import { DentistasView, EspecialidadesView, PlanosView, SyncView, ProcedimentosView } from './views/AdminViews.tsx';
import { NotificationsView } from './views/NotificationsView.tsx';
import { ReportsView } from './views/ReportsView.tsx';
import { ComissoesView } from './views/ComissoesView.tsx';
import { GlosasView } from './views/GlosasView.tsx';
import { SalaHomeView } from './views/SalaHomeView.tsx';
import { LabHomeView } from './views/LabHomeView.tsx';
import { LabAgendaView } from './views/LabAgendaView.tsx';
import { LabClientesView } from './views/LabClientesView.tsx';
import { LabCotacoesView } from './views/LabCotacoesView.tsx';
import { LabEquipeView } from './views/LabEquipeView.tsx';
import { LabIndicadoresView } from './views/LabIndicadoresView.tsx';
import { ConfiguracoesView } from './views/ConfiguracoesView.tsx';
import { OrcamentosView } from './views/OrcamentosView.tsx';
import { ContratosView } from './views/ContratosView.tsx';
import { PluginsView } from './views/PluginsView.tsx';
import { RXPlugin } from './views/plugins/RXPlugin.tsx';
import { SalasPlugin } from './views/plugins/SalasPlugin.tsx';
import { ProfissionaisPlugin } from './views/plugins/ProfissionaisPlugin.tsx';
import { isPluginActive } from './views/plugins/pluginRegistry.ts';
import { CreateClinicView } from './views/CreateClinicView.tsx';
import { RxConfigView } from './views/RxConfigView.tsx';
import { GestaoEquipeView } from './views/GestaoEquipeView.tsx';
import { TutorPainelView } from './views/TutorPainelView.tsx';
import { TutorCandidaturaView } from './views/TutorCandidaturaView.tsx';
import { AdminGestaoTutoresView } from './views/AdminGestaoTutoresView.tsx';
import { WaitingInviteView } from './views/WaitingInviteView.tsx';
import { SuperAdminView } from './views/SuperAdminView.tsx';
import { DiretorioProfissionaisView } from './views/DiretorioProfissionaisView.tsx';
import { ProteseView } from './views/ProteseView.tsx';
export type ViewKey =
| 'landing'
| 'dashboard'
| 'leads'
| 'public'
| 'pacientes'
| 'agenda'
| 'ortodontia'
| 'financeiro'
| 'comissoes'
| 'glosas'
| 'proteses'
| 'login'
| 'update'
| 'dentistas'
| 'especialidades'
| 'procedimentos'
| 'planos'
| 'tratamentos'
| 'lancar-gto'
| 'notificacoes'
| 'clinicas'
| 'sync'
| 'relatorios'
| 'cadastro-dentista'
| 'configuracoes'
| 'orcamentos'
| 'contratos'
| 'plugins'
| 'rx-radiografias'
| 'wa-inbox'
| 'wa-sessions'
| 'wa-secretaria'
| 'salas'
| 'minhas-salas'
| 'alugar-salas'
| 'sala-home'
| 'lab-home'
| 'lab-bancada'
| 'lab-agenda'
| 'lab-clientes'
| 'lab-financeiro'
| 'lab-cotacoes'
| 'lab-equipe'
| 'lab-indicadores'
| 'marketplace-profissionais'
| 'criar-clinica'
| 'rx-config'
| 'gestao-equipe'
| 'tutoria-painel'
| 'aguardando-convite'
| 'candidatura-tutor'
| 'admin-gestao-tutores'
| 'superadmin'
| 'superadmin-overview'
| 'superadmin-users'
| 'superadmin-clinicas'
| 'superadmin-logs'
| 'superadmin-financeiro'
| 'superadmin-notificacoes'
| 'superadmin-secretaria'
| 'diretorio-profissionais';
// ------- URL <-> VIEW mapping -------
const ROUTE_MAP: Record<string, ViewKey> = {
'/': 'landing',
'/dashboard': 'dashboard',
'/landing': 'landing',
'/leads': 'leads',
'/contato': 'public',
'/pacientes': 'pacientes',
'/agenda': 'agenda',
'/ortodontia': 'ortodontia',
'/financeiro': 'financeiro',
'/login': 'login',
'/update': 'update',
'/clinicas': 'clinicas',
'/admin/dentistas': 'dentistas',
'/admin/especialidades': 'especialidades',
'/admin/procedimentos': 'procedimentos',
'/admin/planos': 'planos',
'/admin/sync': 'sync',
'/tratamentos': 'tratamentos',
'/lancar-gto': 'lancar-gto',
'/notificacoes': 'notificacoes',
'/relatorios': 'relatorios',
'/cadastro-dentista': 'cadastro-dentista',
'/configuracoes': 'configuracoes',
'/orcamentos': 'orcamentos',
'/contratos': 'contratos',
'/plugins': 'plugins',
'/rx-radiografias': 'rx-radiografias',
'/wa-inbox': 'wa-inbox',
'/wa-sessions': 'wa-sessions',
'/wa-secretaria': 'wa-secretaria',
'/salas': 'salas',
'/minhas-salas': 'minhas-salas',
'/alugar-salas': 'alugar-salas',
'/sala-home': 'sala-home',
'/lab-home': 'lab-home',
'/lab-bancada': 'lab-bancada',
'/lab-agenda': 'lab-agenda',
'/lab-clientes': 'lab-clientes',
'/lab-financeiro': 'lab-financeiro',
'/lab-cotacoes': 'lab-cotacoes',
'/lab-equipe': 'lab-equipe',
'/lab-indicadores': 'lab-indicadores',
'/marketplace-profissionais': 'marketplace-profissionais',
'/criar-clinica': 'criar-clinica',
'/rx-config': 'rx-config',
'/gestao-equipe': 'gestao-equipe',
'/tutoria-painel': 'tutoria-painel',
'/candidatura-tutor': 'candidatura-tutor',
'/admin-gestao-tutores': 'admin-gestao-tutores',
'/superadmin': 'superadmin-overview',
'/superadmin/overview': 'superadmin-overview',
'/superadmin/users': 'superadmin-users',
'/superadmin/clinicas': 'superadmin-clinicas',
'/superadmin/logs': 'superadmin-logs',
'/superadmin/financeiro': 'superadmin-financeiro',
'/superadmin/notificacoes': 'superadmin-notificacoes',
'/superadmin/secretaria': 'superadmin-secretaria',
'/aguardando-convite': 'aguardando-convite',
'/diretorio-profissionais': 'diretorio-profissionais',
'/proteses': 'proteses',
// Corrige deep-link/refresh: existiam em VIEW_TO_ROUTE mas faltavam aqui (caíam em dashboard).
'/comissoes': 'comissoes',
'/glosas': 'glosas',
};
const VIEW_TO_ROUTE: Record<ViewKey, string> = {
landing: '/',
dashboard: '/dashboard',
leads: '/leads',
public: '/contato',
pacientes: '/pacientes',
agenda: '/agenda',
ortodontia: '/ortodontia',
financeiro: '/financeiro',
comissoes: '/comissoes',
glosas: '/glosas',
login: '/login',
update: '/update',
clinicas: '/clinicas',
dentistas: '/admin/dentistas',
especialidades: '/admin/especialidades',
procedimentos: '/admin/procedimentos',
planos: '/admin/planos',
tratamentos: '/tratamentos',
'lancar-gto': '/lancar-gto',
notificacoes: '/notificacoes',
relatorios: '/relatorios',
sync: '/admin/sync',
'cadastro-dentista': '/cadastro-dentista',
configuracoes: '/configuracoes',
orcamentos: '/orcamentos',
contratos: '/contratos',
plugins: '/plugins',
'rx-radiografias': '/rx-radiografias',
'wa-inbox': '/wa-inbox',
'wa-sessions': '/wa-sessions',
'wa-secretaria': '/wa-secretaria',
salas: '/salas',
'minhas-salas': '/minhas-salas',
'alugar-salas': '/alugar-salas',
'sala-home': '/sala-home',
'lab-home': '/lab-home',
'lab-bancada': '/lab-bancada',
'lab-agenda': '/lab-agenda',
'lab-clientes': '/lab-clientes',
'lab-financeiro': '/lab-financeiro',
'lab-cotacoes': '/lab-cotacoes',
'lab-equipe': '/lab-equipe',
'lab-indicadores': '/lab-indicadores',
'marketplace-profissionais': '/marketplace-profissionais',
'criar-clinica': '/criar-clinica',
'rx-config': '/rx-config',
'gestao-equipe': '/gestao-equipe',
'tutoria-painel': '/tutoria-painel',
'candidatura-tutor': '/candidatura-tutor',
'admin-gestao-tutores': '/admin-gestao-tutores',
'aguardando-convite': '/aguardando-convite',
superadmin: '/superadmin',
'superadmin-overview': '/superadmin/overview',
'superadmin-users': '/superadmin/users',
'superadmin-clinicas': '/superadmin/clinicas',
'superadmin-logs': '/superadmin/logs',
'superadmin-financeiro': '/superadmin/financeiro',
'superadmin-notificacoes': '/superadmin/notificacoes',
'superadmin-secretaria': '/superadmin/secretaria',
'diretorio-profissionais': '/diretorio-profissionais',
proteses: '/proteses',
};
/** Resolve current URL to a ViewKey */
function resolveViewFromUrl(): ViewKey {
const path = window.location.pathname || '/';
return ROUTE_MAP[path] ?? 'dashboard';
}
/** Push a new URL without reloading the page */
function navigate(view: ViewKey) {
const route = VIEW_TO_ROUTE[view];
history.pushState({}, '', route);
}
// ------- Mobile Bottom Nav -------
const BOTTOM_TABS: Record<string, Array<{ id: ViewKey; icon: React.ElementType; label: string } | { more: true }>> = {
admin: [{ id: 'dashboard', icon: LayoutDashboard, label: 'Início' }, { id: 'pacientes', icon: Users, label: 'Pacientes' }, { id: 'agenda', icon: CalendarIcon, label: 'Agenda' }, { id: 'financeiro', icon: DollarSign, label: 'Financeiro' }, { more: true }],
donoclinica: [{ id: 'dashboard', icon: LayoutDashboard, label: 'Início' }, { id: 'pacientes', icon: Users, label: 'Pacientes' }, { id: 'agenda', icon: CalendarIcon, label: 'Agenda' }, { id: 'financeiro', icon: DollarSign, label: 'Financeiro' }, { more: true }],
donoconsultorio: [{ id: 'dashboard', icon: LayoutDashboard, label: 'Início' }, { id: 'pacientes', icon: Users, label: 'Pacientes' }, { id: 'agenda', icon: CalendarIcon, label: 'Agenda' }, { id: 'financeiro', icon: DollarSign, label: 'Financeiro' }, { more: true }],
funcionario: [{ id: 'dashboard', icon: LayoutDashboard, label: 'Início' }, { id: 'pacientes', icon: Users, label: 'Pacientes' }, { id: 'agenda', icon: CalendarIcon, label: 'Agenda' }, { id: 'financeiro', icon: DollarSign, label: 'Financeiro' }, { more: true }],
dentista: [{ id: 'dashboard', icon: LayoutDashboard, label: 'Início' }, { id: 'agenda', icon: CalendarIcon, label: 'Agenda' }, { id: 'ortodontia', icon: Activity, label: 'Orto' }, { more: true }],
biomedico: [{ id: 'dashboard', icon: LayoutDashboard, label: 'Início' }, { id: 'agenda', icon: CalendarIcon, label: 'Agenda' }, { id: 'tratamentos', icon: ClipboardList, label: 'Tratam.' }, { more: true }],
protetico: [{ id: 'dashboard', icon: LayoutDashboard, label: 'Início' }, { id: 'agenda', icon: CalendarIcon, label: 'Agenda' }, { id: 'lancar-gto', icon: ClipboardList, label: 'GTO' }, { more: true }],
paciente: [{ id: 'tratamentos', icon: ClipboardList, label: 'Tratamentos' }, { id: 'notificacoes', icon: Bell, label: 'Notif.' }, { id: 'configuracoes', icon: Settings, label: 'Config.' }],
};
const MobileBottomNav: React.FC<{
activeTab: ViewKey;
role: string;
clinColor: string;
onNavigate: (v: ViewKey) => void;
onMore: () => void;
}> = ({ activeTab, role, clinColor, onNavigate, onMore }) => {
const tabs = BOTTOM_TABS[role] || BOTTOM_TABS['funcionario'];
return (
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-40">
<div className="flex items-stretch justify-around" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
{tabs.map((tab, i) => {
if ('more' in tab) return (
<button key="more" onClick={onMore}
className="flex flex-col items-center justify-center gap-0.5 py-2 px-3 flex-1 text-gray-400 active:bg-gray-50">
<MoreHorizontal size={22} />
<span className="text-[9px] font-black uppercase">Mais</span>
</button>
);
const Icon = tab.icon;
const isActive = activeTab === (tab as any).id;
return (
<button key={i} onClick={() => onNavigate((tab as any).id)}
className="flex flex-col items-center justify-center gap-0.5 py-2 px-3 flex-1 active:bg-gray-50 transition-colors"
style={{ color: isActive ? clinColor : undefined }}>
<Icon size={22} className={isActive ? '' : 'text-gray-400'} />
<span className={`text-[9px] font-black uppercase ${isActive ? '' : 'text-gray-400'}`}>{(tab as any).label}</span>
</button>
);
})}
</div>
</nav>
);
};
// ------- App -------
const App: React.FC = () => {
const ver = useAppVersion();
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).
const [isSidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
try { return localStorage.getItem('scoreodonto:sidebar-collapsed') === '1'; } catch { return false; }
});
const toggleSidebarCollapsed = (v: boolean) => {
setSidebarCollapsed(v);
try { localStorage.setItem('scoreodonto:sidebar-collapsed', v ? '1' : '0'); } catch { /* ignore */ }
};
const [, forcePwRerender] = useState(0); // re-avalia o modal de troca obrigatória de senha
const currentRole = HybridBackend.getCurrentRole();
const activeWorkspace = HybridBackend.getActiveWorkspace();
const clinColor = activeWorkspace?.cor || '#2563eb';
const isViewAllowed = (view: ViewKey, role: string): boolean => {
if (['landing', 'login', 'public'].includes(view)) return true;
// /update (migração de banco) é EXCLUSIVA do superadmin (liberado na linha seguinte).
if (role === 'superadmin') return true;
// Sync GLOBAL removido do app (substituído pelo backup por clínica em Configurações).
if (view === 'sync') return false;
// 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-sessions' || 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.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'marketplace-profissionais', 'diretorio-profissionais', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
// Catálogo de PLUGINS: EXCLUSIVO do superadmin (já retorna true acima); ninguém mais.
if (view === 'plugins') return false;
// Admin/donos têm acesso amplo, MAS as telas de superadmin são exclusivas do superadmin.
if (role === 'admin' || role === 'donoclinica' || role === 'donoconsultorio') return !view.startsWith('superadmin');
// Equipe: acesso por CARGO (RBAC) no workspace ativo.
const ws: any = HybridBackend.getActiveWorkspace();
if (ws && ['donoclinica', 'donoconsultorio', 'admin'].includes(ws.role)) return !view.startsWith('superadmin');
// Páginas sempre disponíveis a qualquer membro (navegação/conta).
if (['configuracoes', 'notificacoes', 'clinicas'].includes(view)) return true;
// Só gateia por cargo quando há permissões EXPLÍCITAS. Cargo sem permissões
// (ou membro sem cargo) herda o menu padrão do role (fallback abaixo).
if (Array.isArray(ws?.permissoes) && ws.permissoes.length > 0) return (ws.permissoes as string[]).includes(view);
const permissions: Record<string, ViewKey[]> = {
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
protetico: ['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'proteses', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'],
};
return permissions[role]?.includes(view) || false;
};
const getDefaultViewForRole = (role: string): ViewKey => {
if (role === 'superadmin') return 'superadmin-overview';
// Contexto de SALA (workspace tipo='sala') sempre cai no painel da sala.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return 'sala-home';
// Contexto de LABORATÓRIO (workspace tipo='laboratorio') cai no painel do lab.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return 'lab-home';
if (role === 'paciente') return 'tratamentos';
if (role === 'donosala') return 'minhas-salas';
return 'dashboard';
};
const getStoredUserRole = (): string => {
try {
const raw = localStorage.getItem('SCOREODONTO_USER_DATA');
return raw ? (JSON.parse(raw).role ?? 'paciente') : 'paciente';
} catch { return 'paciente'; }
};
const getInitialView = (): ViewKey => {
const fromUrl = resolveViewFromUrl();
const isAuthed = HybridBackend.isAuthenticated();
// Superadmin pode acessar páginas públicas (landing / contato) diretamente,
// sem ser rebotado de volta para o painel.
if (isAuthed && getStoredUserRole() === 'superadmin' && ['landing', 'public'].includes(fromUrl)) {
return fromUrl;
}
// Authenticated but no workspace → route to the right post-registration screen
if (isAuthed && !HybridBackend.getActiveWorkspace()) {
const storedRole = getStoredUserRole();
if (storedRole === 'superadmin') return 'superadmin-overview';
if (storedRole === 'admin' || storedRole === 'donoclinica' || storedRole === 'donoconsultorio') return 'criar-clinica';
if (storedRole === 'donosala') return 'minhas-salas';
if (storedRole === 'dentista' || storedRole === 'biomedico' || storedRole === 'protetico') return 'aguardando-convite';
return 'aguardando-convite';
}
const role = HybridBackend.getCurrentRole();
// Authenticated visiting landing/login → default for role
if (isAuthed && ['landing', 'login'].includes(fromUrl)) return getDefaultViewForRole(role);
// Protected route without auth → landing
const isProtected = !['landing', 'login', 'public'].includes(fromUrl);
if (isProtected && !isAuthed) return 'landing';
// Authenticated but lacking permission for the requested view
if (isAuthed && !isViewAllowed(fromUrl, role)) return getDefaultViewForRole(role);
return fromUrl;
};
const [currentView, setCurrentView] = useState<ViewKey>(getInitialView);
const handleNavigate = (view: ViewKey) => {
const freshRole = HybridBackend.getCurrentRole();
if (isViewAllowed(view, freshRole)) {
setCurrentView(view);
navigate(view);
setSidebarOpen(false);
}
};
useEffect(() => {
const onHashChange = () => {
const fromUrl = resolveViewFromUrl();
const role = HybridBackend.getCurrentRole();
const isProtected = !['landing', 'login', 'public'].includes(fromUrl);
if (isProtected && !HybridBackend.isAuthenticated()) {
setCurrentView('landing');
navigate('landing');
return;
}
if (HybridBackend.isAuthenticated() && !isViewAllowed(fromUrl, role)) {
const defView = getDefaultViewForRole(role);
setCurrentView(defView);
navigate(defView);
return;
}
setCurrentView(fromUrl);
};
window.addEventListener('popstate', onHashChange);
return () => window.removeEventListener('popstate', onHashChange);
}, []);
useEffect(() => {
document.documentElement.style.setProperty('--brand-color', clinColor);
document.documentElement.style.setProperty('--brand-color-soft', `${clinColor}11`);
}, [clinColor]);
useEffect(() => {
navigate(currentView);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sincroniza os plugins ativados na conta (servidor → cache local) ao montar,
// para o menu de Salas refletir a ativação mesmo em outro dispositivo.
useEffect(() => {
if (HybridBackend.isAuthenticated() && !HybridBackend.isPreviewMode()) {
HybridBackend.hydrateMyPlugins().then(() => forcePwRerender(t => t + 1));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Roteamento pós-login/cadastro.
const onAuthSuccess = (result?: { noWorkspace?: boolean; userRole?: string }) => {
if (result?.noWorkspace) {
const userRole = result.userRole || 'dentista';
if (userRole === 'superadmin') { setCurrentView('superadmin'); navigate('superadmin'); }
else if (userRole === 'donosala') { setCurrentView('minhas-salas'); navigate('minhas-salas'); }
else if (userRole === 'admin' || userRole === 'donoclinica' || userRole === 'donoconsultorio') { setCurrentView('criar-clinica'); navigate('criar-clinica'); }
else { setCurrentView('aguardando-convite'); navigate('aguardando-convite'); }
return;
}
const storedRole = getStoredUserRole();
if (storedRole === 'superadmin') { setCurrentView('superadmin-overview'); navigate('superadmin-overview'); return; }
if (storedRole === 'donoconsultorio') { setCurrentView('dashboard'); navigate('dashboard'); return; }
const role = HybridBackend.getCurrentRole();
const view = getDefaultViewForRole(role);
setCurrentView(view);
navigate(view);
};
const renderView = () => {
switch (currentView) {
case 'landing': return <LandingPage
onGetStarted={() => handleNavigate('login')}
isAuthenticated={HybridBackend.isAuthenticated()}
userName={HybridBackend.getCurrentUserName()}
onGoToArea={() => handleNavigate(getDefaultViewForRole(HybridBackend.getCurrentRole()))}
/>;
case 'dashboard': return <Dashboard />;
case 'leads': return <LeadsView />;
case 'public': return <PublicContactForm />;
case 'pacientes': return <PatientsView />;
case 'agenda': return <AgendaView onNavigate={handleNavigate} sidebarCollapsed={isSidebarCollapsed} onToggleSidebar={() => toggleSidebarCollapsed(!isSidebarCollapsed)} controlsOpen={agendaControlsOpen} onCloseControls={() => setAgendaControlsOpen(false)} />;
case 'ortodontia': return <OrthoView />;
case 'financeiro': return <FinanceiroView />;
case 'dentistas': return <DentistasView />;
case 'especialidades': return <EspecialidadesView />;
case 'procedimentos': return <ProcedimentosView />;
case 'planos': return <PlanosView />;
case 'sync': return <SyncView />;
case 'tratamentos': return <MeusTratamentos />;
case 'lancar-gto': return <LancarGTO />;
case 'update': return <UpdateDbView />;
case 'clinicas': return <ClinicasView />;
case 'notificacoes': return <NotificationsView />;
case 'relatorios': return <ReportsView />;
case 'comissoes': return <ComissoesView />;
case 'glosas': return <GlosasView />;
case 'proteses': return <ProteseView />;
case 'cadastro-dentista': return <DentistRegisterView />;
case 'configuracoes': return <ConfiguracoesView />;
case 'orcamentos': return <OrcamentosView />;
case 'contratos': return <ContratosView />;
case 'plugins': return <PluginsView />;
case 'rx-radiografias': return <RXPlugin />;
case 'wa-inbox': return <InboxView onNavigate={handleNavigate} />;
case 'wa-sessions': return <SessionsView onNavigate={handleNavigate} />;
case 'wa-secretaria': return <SecretariaView onNavigate={handleNavigate} />;
case 'salas': return <SalasPlugin />;
case 'minhas-salas': return <SalasPlugin view="minhas" />;
case 'alugar-salas': return <SalasPlugin view="alugar" />;
case 'sala-home': return <SalaHomeView />;
case 'lab-home': return <LabHomeView />;
case 'lab-bancada': return <ProteseView />;
case 'lab-agenda': return <LabAgendaView />;
case 'lab-clientes': return <LabClientesView tab="clientes" />;
case 'lab-financeiro': return <LabClientesView tab="financeiro" />;
case 'lab-cotacoes': return <LabCotacoesView />;
case 'lab-equipe': return <LabEquipeView />;
case 'lab-indicadores': return <LabIndicadoresView />;
case 'marketplace-profissionais': return <ProfissionaisPlugin />;
case 'criar-clinica': return <CreateClinicView onCreated={() => { window.location.reload(); }} />;
case 'rx-config': return <RxConfigView />;
case 'gestao-equipe': return <GestaoEquipeView />;
case 'tutoria-painel': return <TutorPainelView />;
case 'candidatura-tutor': return <TutorCandidaturaView />;
case 'admin-gestao-tutores': return <AdminGestaoTutoresView />;
case 'aguardando-convite': return <WaitingInviteView />;
case 'superadmin': return <SuperAdminView tab="overview" />;
case 'superadmin-overview': return <SuperAdminView tab="overview" />;
case 'superadmin-users': return <SuperAdminView tab="users" />;
case 'superadmin-clinicas': return <SuperAdminView tab="clinicas" />;
case 'superadmin-logs': return <SuperAdminView tab="logs" />;
case 'superadmin-financeiro': return <SuperAdminView tab="financeiro" />;
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).
case 'diretorio-profissionais': return isPluginActive('profissionais-marketplace') ? <ProfissionaisPlugin /> : <DiretorioProfissionaisView />;
case 'login':
default:
return <LoginView onLoginSuccess={onAuthSuccess} />;
}
};
// Views do plugin NewWhats rodam em tela cheia (sem sidebar/barra do
// scoreodonto) — cada uma tem seu próprio "Voltar" (onNavigate) para sair.
// /wa-sessions (gestão de sessões WhatsApp) roda COM a sidebar do scoreodonto
// (é área de administração). wa-inbox/wa-secretaria seguem em tela cheia.
const isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista', 'criar-clinica', 'aguardando-convite', 'wa-inbox', 'wa-secretaria'].includes(currentView);
return (
<>
{HybridBackend.isAuthenticated() && HybridBackend.mustChangePassword() && (
<ChangePasswordModal forced onDone={() => forcePwRerender(t => t + 1)} />
)}
<div className="flex h-screen bg-gray-50 text-gray-800 overflow-hidden relative">
<style>{`
:root { --brand-color: ${clinColor}; }
.bg-brand { background-color: var(--brand-color); }
.text-brand { color: var(--brand-color); }
.border-brand { border-color: var(--brand-color); }
.hover\\:bg-brand:hover { background-color: var(--brand-color); }
.focus\\:ring-brand:focus { --tw-ring-color: var(--brand-color); }
`}</style>
{!isStandaloneView && (
<>
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-20 md:hidden backdrop-blur-sm transition-opacity"
onClick={() => setSidebarOpen(false)}
></div>
)}
<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
activeTab={currentView}
setActiveTab={(t) => handleNavigate(t as ViewKey)}
/>
</div>
</>
)}
{/* Botão flutuante para reexibir a sidebar (desktop, quando recolhida).
Na agenda NÃO aparece — ela já tem o próprio toggle ao lado do título. */}
{!isStandaloneView && isSidebarCollapsed && currentView !== 'agenda' && (
<button
onClick={() => toggleSidebarCollapsed(false)}
title="Mostrar menu"
className="hidden md:flex fixed top-3 left-3 z-40 items-center justify-center w-9 h-9 bg-white border border-gray-200 rounded-lg text-gray-500 hover:text-gray-800 hover:border-gray-300 shadow-sm transition-colors"
>
<PanelLeftOpen size={18} />
</button>
)}
<main className={`flex-1 h-full relative flex flex-col min-w-0`}>
{HybridBackend.isPreviewMode() && (
<div className="bg-red-600 text-white px-4 py-2 flex items-center justify-between gap-3 shadow-md z-30">
<div className="flex items-center gap-2 min-w-0">
<Eye size={15} className="shrink-0" />
<span className="text-[11px] font-black uppercase tracking-wide truncate">
Modo visualização vendo a área como {HybridBackend.getPreviewRole()}
</span>
</div>
<button
onClick={() => HybridBackend.exitPreview()}
className="shrink-0 flex items-center gap-1.5 bg-white/15 hover:bg-white/25 px-3 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all"
>
<X size={13} /> Sair da visualização
</button>
</div>
)}
{!isStandaloneView && (
<>
<div className="md:hidden flex items-center justify-between px-4 py-3 bg-white border-b border-gray-200">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<Menu size={24} />
</button>
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded flex items-center justify-center text-white font-bold text-sm shadow-sm" style={{ backgroundColor: clinColor }}>
S
</div>
<span className="font-bold text-gray-800 text-sm tracking-tight uppercase">SCOREODONTO</span>
</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 className={`flex-1 overflow-y-auto scroll-smooth ${isStandaloneView ? '' : 'p-4 md:p-8'}`}>
{isStandaloneView ? (
renderView()
) : (
<div className="max-w-7xl mx-auto h-full">
{renderView()}
</div>
)}
</div>
</main>
</div>
<ToastContainer />
{/* Versão sempre visível no canto inferior direito (lê /api/version; não bloqueia cliques) */}
<div className="fixed bottom-1.5 right-2 z-40 pointer-events-none select-none text-[10px] font-mono text-gray-400/70">
{ver.version}
</div>
</>
);
};
export default App;