Files
scoreodonto.com/frontend/components/Sidebar.tsx
T
VPS 4 Builder 21bebd3256 feat(newwhats): ownership de sessões, auto-provisionamento, assinatura e drawer na agenda
- Drawer de WhatsApp focado (WhatsChatDrawer) aberto do slot do agendamento, com
  dropdown de números do paciente + grupo familiar e fluxo de iniciar conversa
- Auto-provisionamento de conta no motor: eager no cadastro/convite + self-heal no
  proxy (404 "conta não encontrada" → provisiona e refaz o request)
- Ownership por sessão (wa_session_authz): dono do workspace (owner_id ou vínculo
  donoclinica/donoconsultorio) tem poder total; delega re-scan, excluir sessão,
  apagar conversa/mensagem, acesso ao Inbox e à Secretária — por conta
- Enforcement no proxy: guardSessionAction (scan/rescan/delete sessão),
  guardAreaAccess (inbox/secretaria), guardInboxDestructive (apagar conversa/msg)
- Endpoints /api/nw/authz (dono gerencia) e /api/nw/access (acesso agregado)
- Assinatura do operador nas mensagens (*Nome*, desambiguação de homônimos → *Rui C.*)
- UI: painel "Gerenciar acesso" com 5 toggles; menu e botões de apagar condicionados
- Proxy de mídia /api/nw/media + re-download sob demanda

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:26:31 +02:00

339 lines
21 KiB
TypeScript

import React, { useState, useEffect } 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, MessageCircle, Bot, Smartphone
} from 'lucide-react';
import { ToothIcon } from './ToothIcon.tsx';
import { HybridBackend } from '../services/backend.ts';
import { getActivePlugins, isPluginActive } 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; }
})();
// Acesso do usuário às áreas do WhatsApp (Inbox/Secretária). null = ainda carregando
// (fail-open: mostra até saber). Dono e autorizados veem; os demais não.
const [waAccess, setWaAccess] = useState<{ isOwner: boolean; inbox: boolean; secretaria: boolean; delete_msg: boolean } | null>(null);
useEffect(() => {
let alive = true;
if (isPluginActive('newwhats')) {
HybridBackend.getWaAccess().then(a => { if (alive) setWaAccess(a); }).catch(() => {});
}
return () => { alive = false; };
}, [activeWorkspace?.id]);
const canSeeInbox = !waAccess || waAccess.isOwner || waAccess.inbox;
const canSeeSecretaria = !waAccess || waAccess.isOwner || waAccess.secretaria;
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' });
}
}
// NewWhats: Inbox + Secretária IA — para TODOS os usuários quando o plugin está
// ativo (a CONFIG do plugin fica no catálogo PLUGINS, exclusivo do superadmin).
if (isPluginActive('newwhats')) {
if (canSeeInbox) pluginMenuItems.push({ id: 'wa-inbox', label: 'WHATSAPP', icon: MessageCircle, color: '#16a34a' });
// INSTÂNCIAS (gestão/QR) só para o dono; os demais operam via Inbox.
if (!waAccess || waAccess.isOwner) pluginMenuItems.push({ id: 'wa-sessions', label: 'INSTÂNCIAS', icon: Smartphone, color: '#0ea5e9' });
if (canSeeSecretaria) pluginMenuItems.push({ id: 'wa-secretaria', label: 'SECRETÁRIA IA', icon: Bot, color: '#2563eb' });
}
// Locação de Salas: menus de LOCADOR (administra as próprias salas) e LOCATÁRIO (aluga de
// terceiros). São decisões de NEGÓCIO do titular — não cabem a funcionário (subordinado) nem
// a paciente. Funcionário, quando muito, opera a AGENDA da sala (workspace tipo='sala'), não
// a locação. Por isso restringimos a papéis que podem ser titulares de sala.
const PAPEIS_LOCACAO = ['donosala', 'donoclinica', 'donoconsultorio', 'admin', 'dentista', 'biomedico', 'protetico'];
if (PAPEIS_LOCACAO.includes(currentRole)) {
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.
// O protético DEPENDE desta tela para publicar o perfil no diretório e ser encontrado pelas clínicas
// — por isso aparece sempre para ele, mesmo sem o plugin ativado.
if ((activePluginIds.includes('profissionais-marketplace') || currentRole === 'protetico' || currentRole === 'biomedico' || currentRole === 'dentista') && 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: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard },
{ id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical },
{ id: 'lab-agenda', label: 'AGENDA', icon: CalendarIcon },
{ id: 'lab-clientes', label: 'CLIENTES', icon: Building2 },
{ id: 'lab-cotacoes', label: 'COTAÇÕES', icon: FileText },
{ id: 'lab-financeiro', label: 'FINANCEIRO', icon: DollarSign },
{ id: 'lab-indicadores', label: 'INDICADORES', icon: BarChart3 },
{ id: 'lab-equipe', label: 'EQUIPE', icon: UsersRound },
{ 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: 'proteses', label: 'PRÓTESES', icon: FlaskConical },
{ 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';
// PAINEL DO LAB / BANCADA só existem no workspace de laboratório.
if (['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio';
// 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);
// Contexto de LABORATÓRIO (workspace tipo='laboratorio'): área própria do protético —
// painel do lab + bancada de produção, sem as telas clínicas da clínica.
if ((activeWorkspace as any)?.tipo === 'laboratorio') {
// Técnico (vínculo tecnico_protese) só opera a produção; gestão é do dono.
const ehTecnico = (activeWorkspace as any)?.role === 'tecnico_protese';
return ehTecnico
? ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id)
: ['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-indicadores', 'lab-equipe', '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', 'proteses', '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.
// Protético: opera dentro do workspace 'laboratorio' (menu governado pelo branch tipo acima).
// Este é só o fallback fora desse contexto — sem telas clínicas (agenda/GTO/tratamentos).
if (currentRole === 'protetico') return ['notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'proteses', '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>
);
};