import React, { useState, useEffect, useCallback } from 'react'; import { Users, Building, Briefcase, Plus, Trash2, Edit2, X, Check, UserPlus, Shield, ChevronDown, Copy, KeyRound } from 'lucide-react'; import { HybridBackend } from '../services/backend.ts'; import { useToast } from '../contexts/ToastContext.tsx'; import { useConfirm } from '../contexts/ConfirmContext.tsx'; import { PageHeader } from '../components/PageHeader.tsx'; import { GoogleConnectButton } from '../components/GoogleConnectButton.tsx'; type Tab = 'usuarios' | 'setores' | 'funcoes'; const ROLE_LABELS: Record = { donoclinica: 'DONO DA CLÍNICA', admin: 'ADMINISTRADOR', dentista: 'DENTISTA', funcionario: 'FUNCIONÁRIO', paciente: 'PACIENTE', }; const ROLE_COLORS: Record = { donoclinica: 'bg-purple-100 text-purple-800', admin: 'bg-red-100 text-red-800', dentista: 'bg-teal-100 text-teal-800', funcionario: 'bg-green-100 text-green-800', paciente: 'bg-gray-100 text-gray-600', }; interface Membro { id: string; nome: string; email: string; role: string; setor_id?: string | null; funcao_id?: string | null; setor_nome?: string | null; funcao_nome?: string | null; funcao_nivel?: number | null; } interface Setor { id: string; nome: string; descricao?: string; cor: string; } interface Funcao { id: string; nome: string; descricao?: string; permissoes?: string[]; nivel?: number; } // ─── Catálogo de páginas (RBAC) ─────────────────────────────────── // As chaves DEVEM bater com os ids de menu do Sidebar / ViewKey do App. // clinicas/configuracoes/notificacoes são sempre liberadas (navegação/conta) e não entram na matriz. const PAGINAS_RBAC: Array<{ grupo: string; itens: Array<{ key: string; label: string }> }> = [ { grupo: 'ATENDIMENTO', itens: [ { key: 'dashboard', label: 'VISÃO GERAL' }, { key: 'agenda', label: 'AGENDA' }, { key: 'pacientes', label: 'PACIENTES' }, { key: 'ortodontia', label: 'ORTODONTIA' }, { key: 'tratamentos', label: 'TRATAMENTOS' }, { key: 'proteses', label: 'PRÓTESES' }, { key: 'orcamentos', label: 'ORÇAMENTOS' }, { key: 'contratos', label: 'CONTRATOS' }, { key: 'rx-radiografias', label: 'RX / RADIOGRAFIAS' }, ], }, { grupo: 'FINANCEIRO & GESTÃO', itens: [ { key: 'financeiro', label: 'FINANCEIRO' }, { key: 'relatorios', label: 'RELATÓRIOS' }, { key: 'leads', label: 'GESTÃO DE LEADS' }, { key: 'lancar-gto', label: 'LANÇAR GTO' }, ], }, { grupo: 'CADASTROS', itens: [ { key: 'dentistas', label: 'DENTISTAS' }, { key: 'especialidades', label: 'ESPECIALIDADES' }, { key: 'procedimentos', label: 'PROCEDIMENTOS' }, { key: 'planos', label: 'PLANOS / CONVÊNIOS' }, ], }, { grupo: 'EQUIPE', itens: [ { key: 'gestao-equipe', label: 'EQUIPE / USUÁRIOS' }, { key: 'diretorio-profissionais', label: 'DIRETÓRIO PROF.' }, ], }, ]; const TODAS_PAGINAS = PAGINAS_RBAC.flatMap(g => g.itens.map(i => i.key)); // Modelos de cargo prontos (acelera a configuração). const TEMPLATES_CARGO: Array<{ nome: string; nivel: number; permissoes: string[] }> = [ { nome: 'RECEPÇÃO', nivel: 10, permissoes: ['dashboard', 'agenda', 'pacientes', 'leads', 'orcamentos', 'contratos', 'lancar-gto', 'proteses'] }, { nome: 'AUXILIAR / ASB', nivel: 20, permissoes: ['dashboard', 'agenda', 'pacientes', 'ortodontia', 'tratamentos', 'proteses'] }, { nome: 'FINANCEIRO', nivel: 30, permissoes: ['dashboard', 'pacientes', 'financeiro', 'relatorios', 'lancar-gto'] }, { nome: 'GERENTE', nivel: 50, permissoes: [...TODAS_PAGINAS] }, ]; // ─── Modal base ─────────────────────────────────────────────────── const Modal: React.FC<{ title: string; onClose: () => void; children: React.ReactNode }> = ({ title, onClose, children }) => (

{title}

{children}
); // ─── Aba Usuários ───────────────────────────────────────────────── const UsuariosTab: React.FC<{ clinicaId: string; isOwner: boolean; myNivel: number }> = ({ clinicaId, isOwner, myNivel }) => { const efetivoNivel = (m: Membro) => ['donoclinica', 'donoconsultorio', 'admin'].includes(m.role) ? 99999 : (typeof m.funcao_nivel === 'number' ? m.funcao_nivel : 1); const podeGerenciar = (m: Membro) => myNivel > efetivoNivel(m); const toast = useToast(); const confirm = useConfirm(); const [membros, setMembros] = useState([]); const [setores, setSetores] = useState([]); const [funcoes, setFuncoes] = useState([]); const [loading, setLoading] = useState(true); const [showAdd, setShowAdd] = useState(false); const [editing, setEditing] = useState(null); // Add form const [email, setEmail] = useState(''); const [role, setRole] = useState('funcionario'); const [novaCredencial, setNovaCredencial] = useState<{ email: string; senha: string } | null>(null); const [setorId, setSetorId] = useState(''); const [funcaoId, setFuncaoId] = useState(''); const [saving, setSaving] = useState(false); const load = useCallback(async () => { setLoading(true); try { const [m, s, f] = await Promise.all([ HybridBackend.getMembros(clinicaId), HybridBackend.getSetores(clinicaId), HybridBackend.getFuncoes(clinicaId), ]); setMembros(m.membros || []); setSetores(s.setores || []); setFuncoes(f.funcoes || []); } catch { toast.error('ERRO AO CARREGAR MEMBROS.'); } finally { setLoading(false); } }, [clinicaId]); useEffect(() => { load(); }, [load]); // Status do Google da clínica (qual membro tem o calendário conectado). const [googleAccounts, setGoogleAccounts] = useState([]); const loadGoogleStatus = useCallback(async () => { try { const r = await fetch(`/api/auth/google/status?clinicaId=${clinicaId}`); const d = await r.json(); setGoogleAccounts(Array.isArray(d) ? d : []); } catch { /* ignore */ } }, [clinicaId]); useEffect(() => { loadGoogleStatus(); }, [loadGoogleStatus]); const handleAdd = async (e: React.FormEvent) => { e.preventDefault(); setSaving(true); try { const res = await HybridBackend.addMembro(clinicaId, email, role, setorId || undefined, funcaoId || undefined); if (!res.success) { toast.error(res.message || 'ERRO AO ADICIONAR MEMBRO.'); return; } setShowAdd(false); if (res.tempPassword) { setNovaCredencial({ email: res.membro?.email || email, senha: res.tempPassword }); toast.success('CONTA CRIADA! ENVIE A SENHA TEMPORÁRIA AO MEMBRO.'); } else { toast.success('MEMBRO ADICIONADO!'); } setEmail(''); setRole('funcionario'); setSetorId(''); setFuncaoId(''); load(); } catch { toast.error('ERRO AO ADICIONAR MEMBRO.'); } finally { setSaving(false); } }; const handleResetSenha = async (m: Membro) => { if (!await confirm(`Resetar a senha de ${m.nome}? Será gerada uma nova senha temporária (a antiga deixa de funcionar).`)) return; try { const res = await HybridBackend.resetMembroSenha(clinicaId, m.id); if (!res.success || !res.tempPassword) { toast.error(res.message || 'ERRO AO RESETAR SENHA.'); return; } setNovaCredencial({ email: res.email || m.email, senha: res.tempPassword }); toast.success('SENHA RESETADA! ENVIE A NOVA SENHA TEMPORÁRIA.'); } catch { toast.error('ERRO AO RESETAR SENHA.'); } }; const handleUpdate = async () => { if (!editing) return; setSaving(true); try { const res = await HybridBackend.updateMembro(clinicaId, editing.id, { role: editing.role, setor_id: editing.setor_id || null, funcao_id: editing.funcao_id || null, }); if (!res.success) { toast.error(res.message || 'ERRO AO ATUALIZAR.'); return; } toast.success('MEMBRO ATUALIZADO!'); setEditing(null); load(); } catch { toast.error('ERRO AO ATUALIZAR.'); } finally { setSaving(false); } }; const handleRemove = async (userId: string, nome: string) => { if (!await confirm(`REMOVER ${nome} DA CLÍNICA?`)) return; try { const res = await HybridBackend.removeMembro(clinicaId, userId); if (!res.success) { toast.error(res.message || 'ERRO AO REMOVER.'); return; } toast.success('MEMBRO REMOVIDO!'); load(); } catch { toast.error('ERRO AO REMOVER.'); } }; return (

{membros.length} MEMBRO(S)

{(isOwner || myNivel > 1) && ( )}
{loading ? (
CARREGANDO...
) : membros.length === 0 ? (

NENHUM MEMBRO CADASTRADO

) : (
{membros.map(m => (
{m.nome.charAt(0).toUpperCase()}

{m.nome}

{m.email}

{ROLE_LABELS[m.role] || m.role} {m.setor_nome && ( {m.setor_nome} )} {m.funcao_nome && ( {m.funcao_nome} )}
{podeGerenciar(m) && (
)}
))}
)} {showAdd && ( setShowAdd(false)}>
setEmail(e.target.value)} placeholder="usuario@exemplo.com" className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm font-medium focus:outline-none focus:border-teal-400" />

Se ainda não tiver conta, criamos automaticamente e geramos uma senha temporária.

{setores.length > 0 && (
)} {funcoes.length > 0 && (
)}
)} {novaCredencial && ( setNovaCredencial(null)}>

Envie estes dados ao membro — ele troca a senha no 1º acesso.

E-mail

{novaCredencial.email}

Senha temporária

{novaCredencial.senha}

)} {editing && ( setEditing(null)}>
{/* Google Agenda do funcionário — conectado AQUI (quem tem a senha do Gmail autoriza na hora). */}

Conecta o calendário do Gmail desta pessoa: os agendamentos viram cópia de backup e o paciente recebe convite + lembretes. Autorize com a conta dela (precisa da senha do Gmail).

a.owner_id === editing.id)} onStatusChange={loadGoogleStatus} />
)}
); }; // ─── Aba Setores ────────────────────────────────────────────────── const SetoresTab: React.FC<{ clinicaId: string; isOwner: boolean }> = ({ clinicaId, isOwner }) => { const toast = useToast(); const confirm = useConfirm(); const [setores, setSetores] = useState([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); const [editing, setEditing] = useState(null); const [nome, setNome] = useState(''); const [descricao, setDescricao] = useState(''); const [cor, setCor] = useState('#2563eb'); const [saving, setSaving] = useState(false); const load = useCallback(async () => { setLoading(true); try { const res = await HybridBackend.getSetores(clinicaId); setSetores(res.setores || []); } catch { toast.error('ERRO AO CARREGAR SETORES.'); } finally { setLoading(false); } }, [clinicaId]); useEffect(() => { load(); }, [load]); const openAdd = () => { setEditing(null); setNome(''); setDescricao(''); setCor('#2563eb'); setShowForm(true); }; const openEdit = (s: Setor) => { setEditing(s); setNome(s.nome); setDescricao(s.descricao || ''); setCor(s.cor || '#2563eb'); setShowForm(true); }; const handleSave = async (e: React.FormEvent) => { e.preventDefault(); setSaving(true); try { const data = { nome, descricao, cor }; const res = editing ? await HybridBackend.updateSetor(clinicaId, editing.id, data) : await HybridBackend.saveSetor(clinicaId, data); if (!res.success) { toast.error(res.message || 'ERRO.'); return; } toast.success(editing ? 'SETOR ATUALIZADO!' : 'SETOR CRIADO!'); setShowForm(false); load(); } catch { toast.error('ERRO AO SALVAR.'); } finally { setSaving(false); } }; const handleDelete = async (s: Setor) => { if (!await confirm(`EXCLUIR SETOR "${s.nome}"?`)) return; try { const res = await HybridBackend.deleteSetor(clinicaId, s.id); if (!res.success) { toast.error(res.message || 'ERRO.'); return; } toast.success('SETOR EXCLUÍDO!'); load(); } catch { toast.error('ERRO AO EXCLUIR.'); } }; return (

{setores.length} SETOR(ES)

{isOwner && ( )}
{loading ? (
CARREGANDO...
) : setores.length === 0 ? (

NENHUM SETOR CADASTRADO

) : (
{setores.map(s => (

{s.nome}

{s.descricao &&

{s.descricao}

}
{isOwner && (
)}
))}
)} {showForm && ( setShowForm(false)}>
setNome(e.target.value)} placeholder="Ex: RECEPÇÃO, CLÍNICA GERAL..." className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm font-bold focus:outline-none focus:border-teal-400" />
setDescricao(e.target.value)} placeholder="Descrição opcional..." className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-teal-400" />
setCor(e.target.value)} className="w-10 h-10 rounded-lg cursor-pointer border border-gray-200" /> {cor}
)}
); }; // ─── Aba Funções ────────────────────────────────────────────────── const FuncoesTab: React.FC<{ clinicaId: string; isOwner: boolean }> = ({ clinicaId, isOwner }) => { const toast = useToast(); const confirm = useConfirm(); const [funcoes, setFuncoes] = useState([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); const [editing, setEditing] = useState(null); const [nome, setNome] = useState(''); const [descricao, setDescricao] = useState(''); const [permissoes, setPermissoes] = useState([]); const [nivel, setNivel] = useState(10); const [saving, setSaving] = useState(false); const load = useCallback(async () => { setLoading(true); try { const res = await HybridBackend.getFuncoes(clinicaId); setFuncoes(res.funcoes || []); } catch { toast.error('ERRO AO CARREGAR FUNÇÕES.'); } finally { setLoading(false); } }, [clinicaId]); useEffect(() => { load(); }, [load]); const openAdd = () => { setEditing(null); setNome(''); setDescricao(''); setPermissoes([]); setNivel(10); setShowForm(true); }; const openEdit = (f: Funcao) => { setEditing(f); setNome(f.nome); setDescricao(f.descricao || ''); setPermissoes(Array.isArray(f.permissoes) ? f.permissoes : []); setNivel(typeof f.nivel === 'number' ? f.nivel : 10); setShowForm(true); }; const togglePerm = (key: string) => setPermissoes(p => p.includes(key) ? p.filter(k => k !== key) : [...p, key]); const toggleGrupo = (keys: string[]) => setPermissoes(p => keys.every(k => p.includes(k)) ? p.filter(k => !keys.includes(k)) : Array.from(new Set([...p, ...keys]))); const aplicarTemplate = (t: typeof TEMPLATES_CARGO[number]) => { if (!nome.trim()) setNome(t.nome); setPermissoes([...t.permissoes]); setNivel(t.nivel); }; const handleSave = async (e: React.FormEvent) => { e.preventDefault(); setSaving(true); try { const data = { nome, descricao, permissoes, nivel }; const res = editing ? await HybridBackend.updateFuncao(clinicaId, editing.id, data) : await HybridBackend.saveFuncao(clinicaId, data); if (!res.success) { toast.error(res.message || 'ERRO.'); return; } toast.success(editing ? 'FUNÇÃO ATUALIZADA!' : 'FUNÇÃO CRIADA!'); setShowForm(false); load(); } catch { toast.error('ERRO AO SALVAR.'); } finally { setSaving(false); } }; const handleDelete = async (f: Funcao) => { if (!await confirm(`EXCLUIR FUNÇÃO "${f.nome}"?`)) return; try { const res = await HybridBackend.deleteFuncao(clinicaId, f.id); if (!res.success) { toast.error(res.message || 'ERRO.'); return; } toast.success('FUNÇÃO EXCLUÍDA!'); load(); } catch { toast.error('ERRO AO EXCLUIR.'); } }; return (

{funcoes.length} FUNÇÃO(ÕES)

{isOwner && ( )}
{loading ? (
CARREGANDO...
) : funcoes.length === 0 ? (

NENHUMA FUNÇÃO CADASTRADA

) : (
{funcoes.map(f => (

{f.nome}

NÍVEL {typeof f.nivel === 'number' ? f.nivel : 1} {Array.isArray(f.permissoes) && f.permissoes.length >= TODAS_PAGINAS.length ? 'TODAS AS PÁGINAS' : `${(f.permissoes || []).length} PÁGINA(S)`}
{f.descricao &&

{f.descricao}

}
{isOwner && (
)}
))}
)} {showForm && (

{editing ? 'EDITAR FUNÇÃO' : 'NOVA FUNÇÃO'}

setNome(e.target.value)} placeholder="Ex: RECEPCIONISTA, AUXILIAR..." className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm font-bold focus:outline-none focus:border-teal-400" />
setNivel(Math.max(1, Math.min(99, Number(e.target.value) || 1)))} className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm font-bold focus:outline-none focus:border-teal-400" />
setDescricao(e.target.value)} placeholder="Descrição opcional..." className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-teal-400" />

Quanto MAIOR o nível, mais alto na hierarquia. Um membro só gerencia/edita/remove quem tem nível inferior ao seu.

{TEMPLATES_CARGO.map(t => ( ))}

Minhas Clínicas, Notificações e Configurações ficam sempre disponíveis.

{PAGINAS_RBAC.map(g => { const keys = g.itens.map(i => i.key); const allOn = keys.every(k => permissoes.includes(k)); return (
{g.itens.map(i => { const on = permissoes.includes(i.key); return ( ); })}
); })}
)}
); }; // ─── View Principal ─────────────────────────────────────────────── export const GestaoEquipeView: React.FC = () => { const [tab, setTab] = useState('usuarios'); const activeWorkspace = HybridBackend.getActiveWorkspace(); const currentRole = HybridBackend.getCurrentRole(); const clinicaId = activeWorkspace?.id || ''; const clinicaNome = activeWorkspace?.nome || 'CLÍNICA'; const wsRole = (activeWorkspace as any)?.role; const isOwner = ['donoclinica', 'donoconsultorio', 'admin'].includes(currentRole) || ['donoclinica', 'donoconsultorio', 'admin'].includes(wsRole || ''); const myNivel = isOwner ? Number.MAX_SAFE_INTEGER : (typeof (activeWorkspace as any)?.nivel === 'number' ? (activeWorkspace as any).nivel : 1); const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ { id: 'usuarios', label: 'USUÁRIOS', icon: }, { id: 'setores', label: 'SETORES', icon: }, { id: 'funcoes', label: 'FUNÇÕES', icon: }, ]; return (
{clinicaNome}

OS DADOS ABAIXO SÃO EXCLUSIVOS DESTA EMPRESA — AO TROCAR DE EMPRESA OS DADOS MUDAM AUTOMATICAMENTE.

{/* Tabs */}
{tabs.map(t => ( ))}
{/* Content */}
{tab === 'usuarios' && } {tab === 'setores' && } {tab === 'funcoes' && }
); };