Files
scoreodonto.com/frontend/components/Sidebar.tsx
T
VPS 4 Builder dc5a2d89ae feat(protese): agenda & coleta do laboratório (integrada à custódia)
Novo módulo de logística do lab (doc/AGENDA-COLETA-LAB):
- protese_coleta: agendamento de coleta (lab busca) / entrega (lab leva), com data/hora,
  endereço, responsável, status. Ligado à OS.
- POST /protese/os/:id/coleta (agendar), GET /protese/lab/agenda (cronológica),
  POST /protese/coleta/:id/status. 'realizada' MOVE a custódia automaticamente
  (coleta→laboratorio, entrega→clinica) — fonte única do movimento físico. GET detalhe traz coletas[].
- UI: tela AGENDA (lab-agenda) agrupada por dia com "marcar realizada"; seção Coleta/entrega na
  aba Monitoramento da OS (clínica e lab).

Validado em DEV: agenda coleta → realizada → custódia=laboratorio; lab vê na agenda.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:11:24 +02:00

316 lines
19 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: 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.
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: '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>
);
};