f084656712
Agenda (multi-secretária): - audit_log genérico + autoria (created/updated/canceled_by) + soft-delete + version (lock otimista) - status novo vocabulário + índice único PARCIAL (libera slot ao cancelar/faltar) - endpoints: falta, remarcar, restaurar, historico, pendencias (a reagendar), faltas, familia - presença: heartbeat (DB última visita + Dragonfly ao vivo, janela 45s) - frontend: StackModal, AgendaDetailModal, AgendaPresence; lista a-reagendar; pacienteId no fluxo de criação; chips de família + badge de faltas no modal RBAC por cargo: funcoes.permissoes/nivel; gate de menu por cargo (array vazio herda role); hierarquia só-gerencia-abaixo no backend Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
535 lines
23 KiB
TypeScript
535 lines
23 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
|
|
} from 'lucide-react';
|
|
|
|
import { ToastContainer } from './components/Toast.tsx';
|
|
|
|
import { Dashboard } from './views/Dashboard.tsx';
|
|
import { LeadsView } from './views/LeadsView.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 { ConfiguracoesView } from './views/ConfiguracoesView.tsx';
|
|
import { ContratosView } from './views/ContratosView.tsx';
|
|
import { PluginsView } from './views/PluginsView.tsx';
|
|
import { RXPlugin } from './views/plugins/RXPlugin.tsx';
|
|
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';
|
|
|
|
export type ViewKey =
|
|
| 'landing'
|
|
| 'dashboard'
|
|
| 'leads'
|
|
| 'public'
|
|
| 'pacientes'
|
|
| 'agenda'
|
|
| 'ortodontia'
|
|
| 'financeiro'
|
|
| 'login'
|
|
| 'update'
|
|
| 'dentistas'
|
|
| 'especialidades'
|
|
| 'procedimentos'
|
|
| 'planos'
|
|
| 'tratamentos'
|
|
| 'lancar-gto'
|
|
| 'notificacoes'
|
|
| 'clinicas'
|
|
| 'sync'
|
|
| 'relatorios'
|
|
| 'cadastro-dentista'
|
|
| 'configuracoes'
|
|
| 'contratos'
|
|
| 'plugins'
|
|
| 'rx-radiografias'
|
|
| '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'
|
|
| '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',
|
|
'/contratos': 'contratos',
|
|
'/plugins': 'plugins',
|
|
'/rx-radiografias': 'rx-radiografias',
|
|
'/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',
|
|
'/aguardando-convite': 'aguardando-convite',
|
|
'/diretorio-profissionais': 'diretorio-profissionais',
|
|
};
|
|
|
|
const VIEW_TO_ROUTE: Record<ViewKey, string> = {
|
|
landing: '/',
|
|
dashboard: '/dashboard',
|
|
leads: '/leads',
|
|
public: '/contato',
|
|
pacientes: '/pacientes',
|
|
agenda: '/agenda',
|
|
ortodontia: '/ortodontia',
|
|
financeiro: '/financeiro',
|
|
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',
|
|
contratos: '/contratos',
|
|
plugins: '/plugins',
|
|
'rx-radiografias': '/rx-radiografias',
|
|
'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',
|
|
'diretorio-profissionais': '/diretorio-profissionais',
|
|
};
|
|
|
|
/** 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: 'pacientes', icon: Users, label: 'Pacientes' }, { id: 'agenda', icon: CalendarIcon, label: 'Agenda' }, { id: 'ortodontia', icon: Activity, label: 'Orto' }, { 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 [isSidebarOpen, setSidebarOpen] = useState(false);
|
|
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', 'update'].includes(view)) return true;
|
|
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;
|
|
// 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', 'pacientes', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'contratos', 'plugins', 'rx-radiografias', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
|
|
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'contratos', 'plugins', 'rx-radiografias', 'candidatura-tutor', 'diretorio-profissionais'],
|
|
};
|
|
|
|
return permissions[role]?.includes(view) || false;
|
|
};
|
|
|
|
const getDefaultViewForRole = (role: string): ViewKey => {
|
|
if (role === 'superadmin') return 'superadmin-overview';
|
|
if (role === 'paciente') return 'tratamentos';
|
|
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 === 'dentista' || 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
|
|
}, []);
|
|
|
|
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} />;
|
|
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 'cadastro-dentista': return <DentistRegisterView />;
|
|
case 'configuracoes': return <ConfiguracoesView />;
|
|
case 'contratos': return <ContratosView />;
|
|
case 'plugins': return <PluginsView />;
|
|
case 'rx-radiografias': return <RXPlugin />;
|
|
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 'diretorio-profissionais': return <DiretorioProfissionaisView />;
|
|
case 'login':
|
|
default:
|
|
return <LoginView onLoginSuccess={(result) => {
|
|
if (result?.noWorkspace) {
|
|
const userRole = result.userRole || 'dentista';
|
|
if (userRole === 'superadmin') {
|
|
setCurrentView('superadmin');
|
|
navigate('superadmin');
|
|
} 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 isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista', 'criar-clinica', 'aguardando-convite'].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 transition-transform duration-300 md:relative md:translate-x-0 ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
|
|
<Sidebar
|
|
activeTab={currentView}
|
|
setActiveTab={(t) => handleNavigate(t as ViewKey)}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<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>
|
|
<div className="w-8"></div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 md:p-8 pb-24 md:pb-8 scroll-smooth">
|
|
<div className="max-w-7xl mx-auto h-full">
|
|
{renderView()}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
{!isStandaloneView && (
|
|
<MobileBottomNav
|
|
activeTab={currentView}
|
|
role={currentRole}
|
|
clinColor={clinColor}
|
|
onNavigate={handleNavigate}
|
|
onMore={() => setSidebarOpen(true)}
|
|
/>
|
|
)}
|
|
</div>
|
|
<ToastContainer />
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default App;
|