Files
scoreodonto.com/frontend/components/Sidebar.tsx
T
VPS 4 Builder a991f1fe2c feat(version): versão em runtime via /api/version (Passos 1/3/4)
- buildInfo.ts: hook useAppVersion() consome GET /api/version (fonte única = backend),
  com fallback ao carimbo do bundle; deixa de depender do APP_VERSION manual.
- Versão exibida em Configurações ("Sobre o sistema") + telas públicas (Login/WaitingInvite);
  removida do rodapé do Sidebar.
- compose.prod/deploy-prod.sh: injetam APP_VERSION (=tag) e APP_ENV=PROD em runtime,
  desacoplando a versão semântica do build (commit/build seguem da imagem).
- constants.ts marcado legado + bump V1.0.15 (transitório até o Passo 5; evita colisão de tag).
- DEPLOY-PRODUCAO.md reescrito com status /🧪/🎯 + nota de fase transitória.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 05:19:27 +02:00

290 lines
17 KiB
TypeScript

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
} from 'lucide-react';
import { ToothIcon } from './ToothIcon.tsx';
import { HybridBackend } from '../services/backend.ts';
import { getActivePlugins } from '../views/plugins/pluginRegistry.ts';
interface SidebarProps {
activeTab: string;
setActiveTab: (t: string) => void;
}
export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) => {
const currentRole = HybridBackend.getCurrentRole();
const userName = HybridBackend.getCurrentUserName();
const userEmail = HybridBackend.getCurrentUser();
const activeWorkspace = HybridBackend.getActiveWorkspace();
const clinColor = activeWorkspace?.cor || (currentRole === 'superadmin' ? '#dc2626' : '#2563eb');
const activePluginIds = getActivePlugins();
const isTutor = (() => {
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}').is_tutor === true; } catch { return false; }
})();
const pluginMenuItems: Array<{ id: string; label: string; icon: any; color: string }> = [];
if (activePluginIds.includes('rx-scoreodonto')) {
// Radiografias são ODONTOLÓGICAS: só papéis odonto (dentista + donos/funcionário).
// Biomédico e protético NÃO veem RX.
if (['dentista', 'funcionario', 'admin', 'donoclinica', 'donoconsultorio'].includes(currentRole)) {
pluginMenuItems.push({ id: 'rx-radiografias', label: 'RX / RADIOGRAFIAS', icon: ScanLine, color: '#7c3aed' });
}
// DISPOSITIVOS RX (config + PCs): admin/donos da clínica e superadmin (gestão global).
if (['admin', 'donoclinica', 'donoconsultorio', 'superadmin'].includes(currentRole)) {
pluginMenuItems.push({ id: 'rx-config', label: 'DISPOSITIVOS RX', icon: Monitor, color: '#7c3aed' });
}
}
// Locação de Salas: capability SEMPRE ativa (sem desativar) p/ todos menos superadmin.
// Dois menus: administrar as próprias salas (locador) e alugar salas de terceiros (locatário).
if (currentRole !== 'superadmin') {
pluginMenuItems.push({ id: 'minhas-salas', label: 'MINHAS SALAS', icon: DoorOpen, color: '#0d9488' });
pluginMenuItems.push({ id: 'alugar-salas', label: 'ALUGAR SALAS', icon: Building2, color: '#0d9488' });
}
// Marketplace de Profissionais: clínicas contratam, profissionais divulgam perfil/recebem propostas.
if (activePluginIds.includes('profissionais-marketplace') && currentRole !== 'superadmin' && currentRole !== 'paciente') {
pluginMenuItems.push({ id: 'marketplace-profissionais', label: 'PROFISSIONAIS', icon: UserSearch, color: '#0d9488' });
}
// OBS: o catálogo de PLUGINS é EXCLUSIVO do superadmin (gerência/ativação).
// Não exibir para nenhum outro papel.
const menuItems = [
{ id: 'superadmin-overview', label: 'VISÃO GERAL', icon: TrendingUp },
{ id: 'superadmin-users', label: 'USUÁRIOS', icon: Users },
{ id: 'superadmin-clinicas', label: 'CLÍNICAS', icon: Building2 },
{ id: 'superadmin-logs', label: 'ACESSOS', icon: Activity },
{ id: 'superadmin-financeiro', label: 'FINANCEIRO', icon: DollarSign },
{ id: 'superadmin-notificacoes', label: 'NOTIFICAÇÕES', icon: Bell },
{ id: 'sala-home', label: 'PAINEL DA SALA', icon: DoorOpen },
{ id: 'dashboard', label: 'VISÃO GERAL', icon: LayoutDashboard },
{ id: 'agenda', label: 'AGENDA', icon: CalendarIcon },
{ id: 'pacientes', label: 'PACIENTES', icon: Users },
{ id: 'lancar-gto', label: 'LANÇAR GTO', icon: ClipboardList },
{ id: 'ortodontia', label: 'ORTODONTIA', icon: Activity },
{ id: 'financeiro', label: 'FINANCEIRO', icon: DollarSign },
{ id: 'relatorios', label: 'RELATÓRIOS', icon: BarChart3 },
{ id: 'comissoes', label: 'COMISSÕES', icon: Percent },
{ id: 'glosas', label: 'GLOSAS', icon: ShieldAlert },
{ id: 'orcamentos', label: 'ORÇAMENTOS', icon: FileText },
{ id: 'contratos', label: 'CONTRATOS', icon: FileSignature },
{ id: 'leads', label: 'GESTÃO DE LEADS', icon: Megaphone },
{ id: 'tratamentos', label: 'MEUS TRATAMENTOS', icon: ClipboardList },
{ id: 'clinicas', label: 'MINHAS CLÍNICAS', icon: Building2 },
{ id: 'dentistas', label: 'DENTISTAS', icon: ToothIcon },
{ id: 'especialidades', label: 'ESPECIALIDADES', icon: Award },
{ id: 'procedimentos', label: 'PROCEDIMENTOS', icon: ClipboardList },
{ id: 'planos', label: 'PLANOS / CONVÊNIOS', icon: BookOpen },
{ id: 'gestao-equipe', label: 'EQUIPE / USUÁRIOS', icon: UsersRound },
{ id: 'diretorio-profissionais', label: 'DIRETÓRIO PROF.', icon: UserSearch },
{ id: 'admin-gestao-tutores', label: 'GESTÃO TUTORES', icon: GraduationCap },
{ id: 'candidatura-tutor', label: 'SER TUTOR ORTO', icon: GraduationCap },
{ id: 'tutoria-painel', label: 'PAINEL TUTOR', icon: GraduationCap },
{ id: 'notificacoes', label: 'NOTIFICAÇÕES', icon: Bell },
{ id: 'plugins', label: 'PLUGINS', icon: Puzzle },
{ id: 'configuracoes', label: 'CONFIGURAÇÕES', icon: Settings },
].filter(item => {
// superadmin: itens específicos de administração global
if (currentRole === 'superadmin') return ['superadmin-overview', 'superadmin-users', 'superadmin-clinicas', 'superadmin-logs', 'superadmin-financeiro', 'superadmin-notificacoes', 'plugins', 'configuracoes'].includes(item.id);
// telas de superadmin são exclusivas do superadmin (não vazam p/ admin/donos)
if (item.id.startsWith('superadmin')) return false;
// PAINEL DA SALA só existe quando o workspace ativo é uma sala.
if (item.id === 'sala-home') return (activeWorkspace as any)?.tipo === 'sala';
// Contexto de SALA (workspace tipo='sala'): isola da clínica — menu base só conta/notificações.
// (MINHAS SALAS / ALUGAR SALAS são itens de plugin, mostrados à parte.)
if ((activeWorkspace as any)?.tipo === 'sala') return ['sala-home', 'notificacoes', 'configuracoes'].includes(item.id);
// plugins e gestão de tutores: exclusivo superadmin
if (item.id === 'plugins' || item.id === 'admin-gestao-tutores') return false;
// diretório antigo é absorvido pelo plugin Profissionais quando ele está ativo
if (item.id === 'diretorio-profissionais' && activePluginIds.includes('profissionais-marketplace')) return false;
// painel tutor: só para tutores aprovados
if (item.id === 'tutoria-painel') return isTutor;
// RBAC por CARGO: membro não-dono com permissões definidas no workspace ativo
const wsRole = (activeWorkspace as any)?.role;
const wsPerms = (activeWorkspace as any)?.permissoes;
const isWsOwner = ['admin', 'donoclinica', 'donoconsultorio'].includes(wsRole || '');
if (!isWsOwner && Array.isArray(wsPerms) && wsPerms.length > 0) {
// Navegação/conta sempre visível
if (['clinicas', 'configuracoes', 'notificacoes'].includes(item.id)) return true;
return wsPerms.includes(item.id);
}
if (['admin', 'donoclinica', 'donoconsultorio'].includes(currentRole)) return true;
// Dono de sala (locador não-clínico): só conta/notificações; salas vêm como itens de plugin.
if (currentRole === 'donosala') return ['notificacoes', 'configuracoes'].includes(item.id);
if (currentRole === 'paciente') return ['tratamentos', 'notificacoes', 'configuracoes'].includes(item.id);
// Dentista: clínico odonto completo (ortodontia, tutoria orto).
if (currentRole === 'dentista') return ['dashboard', 'tratamentos', 'agenda', 'ortodontia', 'notificacoes', 'clinicas', 'configuracoes', 'candidatura-tutor', 'diretorio-profissionais'].includes(item.id);
// Biomédico(a): seu próprio catálogo (especialidades/procedimentos de biomedicina);
// SEM itens odontológicos (ortodontia, RX, tutoria orto).
if (currentRole === 'biomedico') return ['dashboard', 'agenda', 'tratamentos', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
// Protético: laboratório — agenda própria + GTO; sem ortodontia/RX/pacientes clínicos.
if (currentRole === 'protetico') return ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'agenda', 'financeiro', 'relatorios', 'glosas', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'diretorio-profissionais'].includes(item.id);
return false;
});
const confirm = useConfirm();
const [wsOpen, setWsOpen] = React.useState(false);
const handleLogout = async () => {
if (await confirm('TEM CERTEZA QUE DESEJA SAIR DO SISTEMA?')) {
HybridBackend.logout();
}
};
const workspaceName = activeWorkspace?.nome || 'SCOREODONTO';
const activeIsSala = (activeWorkspace as any)?.tipo === 'sala';
const WorkspaceIcon = activeIsSala ? DoorOpen : currentRole === 'donoconsultorio' ? Briefcase : Building2;
// Workspaces para o seletor: clínicas (via vínculo) + salas próprias, agrupados.
const allWorkspaces: any[] = HybridBackend.getWorkspaces() || [];
const wsClinicas = allWorkspaces.filter(w => w.tipo !== 'sala');
const wsSalas = allWorkspaces.filter(w => w.tipo === 'sala');
const canSwitch = allWorkspaces.length > 1;
return (
<div className="w-64 bg-white border-r border-gray-200 h-screen flex flex-col shadow-xl md:shadow-none">
<div className="p-6 pb-3 relative">
<button type="button" onClick={() => canSwitch && setWsOpen(o => !o)} className={`w-full flex items-center gap-3 ${canSwitch ? 'hover:bg-gray-50 rounded-xl -m-1 p-1' : 'cursor-default'} transition-colors`}>
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white font-bold shadow-lg transition-colors duration-500 shrink-0" style={{ backgroundColor: clinColor }}>
<WorkspaceIcon size={16} />
</div>
<div className="min-w-0 flex-1 text-left">
<span className="font-bold text-sm text-gray-800 block leading-tight tracking-tight truncate uppercase">{workspaceName}</span>
<span className="text-[9px] text-gray-400 font-bold uppercase tracking-wider">{activeIsSala ? 'SALA · LOCAÇÃO' : 'SCOREODONTO'}</span>
</div>
{canSwitch && <ChevronDown size={16} className={`text-gray-300 shrink-0 transition-transform ${wsOpen ? 'rotate-180' : ''}`} />}
</button>
{wsOpen && canSwitch && (
<>
<div className="fixed inset-0 z-10" onClick={() => setWsOpen(false)} />
<div className="absolute left-4 right-4 top-[72px] z-20 bg-white border border-gray-200 rounded-2xl shadow-xl py-2 max-h-[60vh] overflow-y-auto">
{wsClinicas.length > 0 && <p className="px-4 py-1.5 text-[9px] font-black text-gray-400 uppercase tracking-widest">Clínicas</p>}
{wsClinicas.map(w => (
<button key={w.id} onClick={() => { setWsOpen(false); HybridBackend.switchWorkspace(w.id); }}
className={`w-full flex items-center gap-2.5 px-4 py-2 hover:bg-gray-50 text-left ${activeWorkspace?.id === w.id ? 'bg-gray-50' : ''}`}>
<span className="w-6 h-6 rounded-md flex items-center justify-center text-white shrink-0" style={{ backgroundColor: w.cor || '#2563eb' }}><Building2 size={12} /></span>
<span className="text-xs font-bold text-gray-700 uppercase truncate flex-1">{w.nome}</span>
{activeWorkspace?.id === w.id && <span className="w-2 h-2 rounded-full bg-teal-500 shrink-0" />}
</button>
))}
{wsSalas.length > 0 && <p className="px-4 py-1.5 mt-1 text-[9px] font-black text-gray-400 uppercase tracking-widest border-t border-gray-50 pt-2">Salas</p>}
{wsSalas.map(w => (
<button key={w.id} onClick={() => { setWsOpen(false); HybridBackend.switchWorkspace(w.id); }}
className={`w-full flex items-center gap-2.5 px-4 py-2 hover:bg-gray-50 text-left ${activeWorkspace?.id === w.id ? 'bg-gray-50' : ''}`}>
<span className="w-6 h-6 rounded-md flex items-center justify-center text-white shrink-0" style={{ backgroundColor: '#0d9488' }}><DoorOpen size={12} /></span>
<span className="text-xs font-bold text-gray-700 uppercase truncate flex-1">{w.nome}{w.cidade ? <span className="text-gray-400 font-medium normal-case"> · {w.cidade}/{w.estado}</span> : ''}</span>
{activeWorkspace?.id === w.id && <span className="w-2 h-2 rounded-full bg-teal-500 shrink-0" />}
</button>
))}
</div>
</>
)}
</div>
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto custom-scrollbar">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-[11px] font-bold uppercase transition-all duration-300 group ${isActive
? 'text-white'
: 'text-gray-500 hover:bg-gray-50'
}`}
style={{
backgroundColor: isActive ? clinColor : 'transparent',
boxShadow: isActive ? `0 10px 15px -5px ${clinColor}44` : 'none'
}}
>
<Icon size={18} className={`${isActive ? 'text-white' : 'text-gray-400 group-hover:text-gray-600'} transition-colors`}
style={{ color: isActive ? '#fff' : undefined }} />
{item.label}
</button>
);
})}
{currentRole === 'superadmin' && (
<div className="pt-3 mt-2 border-t border-gray-100">
<p className="px-4 pb-1 text-[9px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5">
<Eye size={11} /> Visualizar como
</p>
{[
{ role: 'donoclinica', label: 'DONO DE CLÍNICA', icon: Building2 },
{ role: 'dentista', label: 'DENTISTA', icon: ToothIcon },
{ role: 'biomedico', label: 'BIOMÉDICO(A)', icon: Sparkles },
{ role: 'protetico', label: 'PROTÉTICO', icon: FlaskConical },
{ role: 'paciente', label: 'PACIENTE', icon: User },
].map(({ role, label, icon: Icon }) => (
<button
key={role}
onClick={() => HybridBackend.enterPreview(role)}
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-[11px] font-bold uppercase text-gray-500 hover:bg-gray-50 transition-all group"
>
<Icon size={18} className="text-gray-400 group-hover:text-gray-600 transition-colors" />
{label}
</button>
))}
</div>
)}
{pluginMenuItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-[11px] font-bold uppercase transition-all duration-300 group ${isActive ? 'text-white' : 'text-gray-500 hover:bg-purple-50'}`}
style={{
backgroundColor: isActive ? item.color : 'transparent',
boxShadow: isActive ? `0 10px 15px -5px ${item.color}44` : 'none'
}}
>
<Icon size={18} className={isActive ? 'text-white' : ''} style={{ color: isActive ? '#fff' : item.color }} />
{item.label}
</button>
);
})}
</nav>
<div className="p-4 border-t border-gray-100 bg-gray-50/50 space-y-2">
<button
onClick={() => setActiveTab('configuracoes')}
className="w-full flex items-center gap-3 px-3 py-2 rounded-xl transition-all group"
style={{
backgroundColor: activeTab === 'configuracoes' ? clinColor : 'transparent',
}}
>
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center text-gray-500 border border-gray-200 flex-shrink-0">
<User size={15} />
</div>
<div className="min-w-0 flex-1 text-left">
<p className={`text-[10px] font-black uppercase truncate leading-none ${activeTab === 'configuracoes' ? 'text-white' : 'text-gray-700'}`}>{userName}</p>
<p className={`text-[9px] font-medium truncate mt-0.5 ${activeTab === 'configuracoes' ? 'text-white/70' : 'text-gray-400'}`}>{userEmail}</p>
</div>
<Settings size={14} className={activeTab === 'configuracoes' ? 'text-white' : 'text-gray-400'} />
</button>
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-2 text-red-500 hover:bg-red-50 rounded-xl text-[10px] font-black uppercase transition-all"
>
<LogOut size={16} />
SAIR DO SISTEMA
</button>
</div>
</div>
);
};