Files
scoreodonto.com/frontend/views/GestaoEquipeView.tsx
T
VPS 4 Builder ba062e92d0 fix(login): render standalone views edge-to-edge e esconde scrollbar do carrossel de onboarding
- App.tsx: wrappers de padding (p-4/md:p-8) e max-w-7xl/mx-auto agora só se aplicam quando não é tela standalone; login/landing/public renderizam full-bleed
- OnboardingChat.tsx: classe ob-noscroll esconde a barra de rolagem do slide intro (mantém scroll), substituindo a custom-scrollbar indefinida no fluxo de login

Inclui também demais alterações pendentes do working tree (snapshot do estado atual de dev).

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

819 lines
42 KiB
TypeScript

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 { PageHeader } from '../components/PageHeader.tsx';
import { GoogleConnectButton } from '../components/GoogleConnectButton.tsx';
type Tab = 'usuarios' | 'setores' | 'funcoes';
const ROLE_LABELS: Record<string, string> = {
donoclinica: 'DONO DA CLÍNICA',
admin: 'ADMINISTRADOR',
dentista: 'DENTISTA',
funcionario: 'FUNCIONÁRIO',
paciente: 'PACIENTE',
};
const ROLE_COLORS: Record<string, string> = {
donoclinica: 'bg-purple-100 text-purple-800',
admin: 'bg-red-100 text-red-800',
dentista: 'bg-blue-100 text-blue-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: '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', 'contratos'] },
{ nome: 'AUXILIAR / ASB', nivel: 20, permissoes: ['dashboard', 'agenda', 'pacientes', 'ortodontia', 'tratamentos'] },
{ 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 }) => (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h3 className="font-black text-sm uppercase text-gray-800">{title}</h3>
<button onClick={onClose} className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"><X size={18} /></button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
// ─── 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 [membros, setMembros] = useState<Membro[]>([]);
const [setores, setSetores] = useState<Setor[]>([]);
const [funcoes, setFuncoes] = useState<Funcao[]>([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [editing, setEditing] = useState<Membro | null>(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<any[]>([]);
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 (!window.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 (!window.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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs text-gray-400 font-bold uppercase">{membros.length} MEMBRO(S)</p>
{(isOwner || myNivel > 1) && (
<button onClick={() => setShowAdd(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-blue-700 transition-all shadow-sm">
<UserPlus size={15} /> <span className="hidden sm:inline">ADICIONAR MEMBRO</span>
</button>
)}
</div>
{loading ? (
<div className="flex items-center justify-center h-32 text-gray-400 text-sm font-bold uppercase">CARREGANDO...</div>
) : membros.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-gray-400 gap-2">
<Users size={28} />
<p className="text-xs font-bold uppercase">NENHUM MEMBRO CADASTRADO</p>
</div>
) : (
<div className="space-y-2">
{membros.map(m => (
<div key={m.id} className="flex items-center gap-4 p-4 bg-white rounded-xl border border-gray-100 hover:border-blue-200 transition-all group">
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 font-black text-sm flex-shrink-0">
{m.nome.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="font-black text-sm text-gray-800 truncate">{m.nome}</p>
<p className="text-[11px] text-gray-400 truncate">{m.email}</p>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${ROLE_COLORS[m.role] || 'bg-gray-100 text-gray-600'}`}>
{ROLE_LABELS[m.role] || m.role}
</span>
{m.setor_nome && (
<span className="text-[9px] font-bold px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-700 uppercase">{m.setor_nome}</span>
)}
{m.funcao_nome && (
<span className="text-[9px] font-bold px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700 uppercase">{m.funcao_nome}</span>
)}
</div>
</div>
{podeGerenciar(m) && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => handleResetSenha(m)} title="Resetar senha"
className="p-2 hover:bg-amber-50 rounded-lg transition-colors text-amber-500">
<KeyRound size={15} />
</button>
<button onClick={() => setEditing({ ...m })}
className="p-2 hover:bg-blue-50 rounded-lg transition-colors text-blue-500">
<Edit2 size={15} />
</button>
<button onClick={() => handleRemove(m.id, m.nome)}
className="p-2 hover:bg-red-50 rounded-lg transition-colors text-red-500">
<Trash2 size={15} />
</button>
</div>
)}
</div>
))}
</div>
)}
{showAdd && (
<Modal title="ADICIONAR MEMBRO" onClose={() => setShowAdd(false)}>
<form onSubmit={handleAdd} className="space-y-4">
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">E-MAIL DO USUÁRIO *</label>
<input type="email" required value={email} onChange={e => 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-blue-400" />
<p className="text-[10px] text-gray-400 mt-1">Se ainda não tiver conta, criamos automaticamente e geramos uma senha temporária.</p>
</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">ACESSO NO SISTEMA *</label>
<select value={role} onChange={e => setRole(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm font-bold focus:outline-none focus:border-blue-400">
<option value="donoclinica">DONO DA CLÍNICA</option>
<option value="dentista">DENTISTA</option>
<option value="funcionario">FUNCIONÁRIO</option>
<option value="paciente">PACIENTE</option>
</select>
</div>
{setores.length > 0 && (
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">SETOR</label>
<select value={setorId} onChange={e => setSetorId(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm font-bold focus:outline-none focus:border-blue-400">
<option value=""> SEM SETOR </option>
{setores.map(s => <option key={s.id} value={s.id}>{s.nome}</option>)}
</select>
</div>
)}
{funcoes.length > 0 && (
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">FUNÇÃO</label>
<select value={funcaoId} onChange={e => setFuncaoId(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm font-bold focus:outline-none focus:border-blue-400">
<option value=""> SEM FUNÇÃO </option>
{funcoes.filter(f => isOwner || (typeof f.nivel === 'number' ? f.nivel : 1) < myNivel).map(f => <option key={f.id} value={f.id}>{f.nome}</option>)}
</select>
</div>
)}
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => setShowAdd(false)}
className="flex-1 py-2.5 border border-gray-200 rounded-xl text-[11px] font-black uppercase hover:bg-gray-50 transition-colors">
CANCELAR
</button>
<button type="submit" disabled={saving}
className="flex-1 py-2.5 bg-blue-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-blue-700 transition-colors disabled:opacity-50">
{saving ? 'ADICIONANDO...' : 'ADICIONAR'}
</button>
</div>
</form>
</Modal>
)}
{novaCredencial && (
<Modal title="CREDENCIAIS DE ACESSO" onClose={() => setNovaCredencial(null)}>
<div className="space-y-4">
<p className="text-xs font-bold text-emerald-700 uppercase leading-relaxed">Envie estes dados ao membro ele troca a senha no 1º acesso.</p>
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4 space-y-3">
<div>
<p className="text-[9px] font-black text-gray-400 uppercase">E-mail</p>
<p className="font-bold text-gray-800 text-sm break-all">{novaCredencial.email}</p>
</div>
<div className="flex items-end justify-between gap-2">
<div>
<p className="text-[9px] font-black text-gray-400 uppercase flex items-center gap-1"><KeyRound size={11} /> Senha temporária</p>
<p className="font-mono font-black text-gray-900 text-lg tracking-widest">{novaCredencial.senha}</p>
</div>
<button type="button" onClick={() => { navigator.clipboard.writeText(`E-mail: ${novaCredencial.email}\nSenha: ${novaCredencial.senha}`); toast.success('CREDENCIAIS COPIADAS!'); }}
className="p-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl flex items-center gap-1 text-[10px] font-black uppercase" title="Copiar">
<Copy size={15} /> Copiar
</button>
</div>
</div>
<button type="button" onClick={() => setNovaCredencial(null)}
className="w-full py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-[11px] font-black uppercase">Concluir</button>
</div>
</Modal>
)}
{editing && (
<Modal title={`EDITAR — ${editing.nome}`} onClose={() => setEditing(null)}>
<div className="space-y-4">
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">ACESSO NO SISTEMA</label>
<select value={editing.role} onChange={e => setEditing({ ...editing, role: e.target.value })}
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm font-bold focus:outline-none focus:border-blue-400">
<option value="donoclinica">DONO DA CLÍNICA</option>
<option value="dentista">DENTISTA</option>
<option value="funcionario">FUNCIONÁRIO</option>
<option value="paciente">PACIENTE</option>
</select>
</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">SETOR</label>
<select value={editing.setor_id || ''} onChange={e => setEditing({ ...editing, setor_id: e.target.value || null })}
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm font-bold focus:outline-none focus:border-blue-400">
<option value=""> SEM SETOR </option>
{setores.map(s => <option key={s.id} value={s.id}>{s.nome}</option>)}
</select>
</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">FUNÇÃO</label>
<select value={editing.funcao_id || ''} onChange={e => setEditing({ ...editing, funcao_id: e.target.value || null })}
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm font-bold focus:outline-none focus:border-blue-400">
<option value=""> SEM FUNÇÃO </option>
{funcoes.filter(f => isOwner || (typeof f.nivel === 'number' ? f.nivel : 1) < myNivel).map(f => <option key={f.id} value={f.id}>{f.nome}</option>)}
</select>
</div>
{/* Google Agenda do funcionário — conectado AQUI (quem tem a senha do Gmail autoriza na hora). */}
<div className="border-t border-gray-100 pt-4">
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">GOOGLE AGENDA DESTE FUNCIONÁRIO</label>
<p className="text-[10px] text-gray-400 mb-2 leading-snug">
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).
</p>
<GoogleConnectButton
ownerId={editing.id}
clinicaId={clinicaId}
isConnected={googleAccounts.some(a => a.owner_id === editing.id)}
onStatusChange={loadGoogleStatus}
/>
</div>
<div className="flex gap-3 pt-2">
<button onClick={() => setEditing(null)}
className="flex-1 py-2.5 border border-gray-200 rounded-xl text-[11px] font-black uppercase hover:bg-gray-50 transition-colors">
CANCELAR
</button>
<button onClick={handleUpdate} disabled={saving}
className="flex-1 py-2.5 bg-blue-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-blue-700 transition-colors disabled:opacity-50">
{saving ? 'SALVANDO...' : 'SALVAR'}
</button>
</div>
</div>
</Modal>
)}
</div>
);
};
// ─── Aba Setores ──────────────────────────────────────────────────
const SetoresTab: React.FC<{ clinicaId: string; isOwner: boolean }> = ({ clinicaId, isOwner }) => {
const toast = useToast();
const [setores, setSetores] = useState<Setor[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Setor | null>(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 (!window.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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs text-gray-400 font-bold uppercase">{setores.length} SETOR(ES)</p>
{isOwner && (
<button onClick={openAdd}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-blue-700 transition-all shadow-sm">
<Plus size={15} /> <span className="hidden sm:inline">NOVO SETOR</span>
</button>
)}
</div>
{loading ? (
<div className="flex items-center justify-center h-32 text-gray-400 text-sm font-bold uppercase">CARREGANDO...</div>
) : setores.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-gray-400 gap-2">
<Building size={28} />
<p className="text-xs font-bold uppercase">NENHUM SETOR CADASTRADO</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{setores.map(s => (
<div key={s.id} className="flex items-center gap-3 p-4 bg-white rounded-xl border border-gray-100 hover:border-blue-200 transition-all group">
<div className="w-3 h-10 rounded-full flex-shrink-0" style={{ backgroundColor: s.cor || '#2563eb' }} />
<div className="flex-1 min-w-0">
<p className="font-black text-sm text-gray-800 truncate">{s.nome}</p>
{s.descricao && <p className="text-[11px] text-gray-400 truncate mt-0.5">{s.descricao}</p>}
</div>
{isOwner && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => openEdit(s)} className="p-2 hover:bg-blue-50 rounded-lg transition-colors text-blue-500"><Edit2 size={15} /></button>
<button onClick={() => handleDelete(s)} className="p-2 hover:bg-red-50 rounded-lg transition-colors text-red-500"><Trash2 size={15} /></button>
</div>
)}
</div>
))}
</div>
)}
{showForm && (
<Modal title={editing ? 'EDITAR SETOR' : 'NOVO SETOR'} onClose={() => setShowForm(false)}>
<form onSubmit={handleSave} className="space-y-4">
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">NOME *</label>
<input type="text" required value={nome} onChange={e => 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-blue-400" />
</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">DESCRIÇÃO</label>
<input type="text" value={descricao} onChange={e => 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-blue-400" />
</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">COR</label>
<div className="flex items-center gap-3">
<input type="color" value={cor} onChange={e => setCor(e.target.value)}
className="w-10 h-10 rounded-lg cursor-pointer border border-gray-200" />
<span className="text-xs text-gray-500 font-mono">{cor}</span>
</div>
</div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => setShowForm(false)}
className="flex-1 py-2.5 border border-gray-200 rounded-xl text-[11px] font-black uppercase hover:bg-gray-50 transition-colors">
CANCELAR
</button>
<button type="submit" disabled={saving}
className="flex-1 py-2.5 bg-blue-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-blue-700 transition-colors disabled:opacity-50">
{saving ? 'SALVANDO...' : 'SALVAR'}
</button>
</div>
</form>
</Modal>
)}
</div>
);
};
// ─── Aba Funções ──────────────────────────────────────────────────
const FuncoesTab: React.FC<{ clinicaId: string; isOwner: boolean }> = ({ clinicaId, isOwner }) => {
const toast = useToast();
const [funcoes, setFuncoes] = useState<Funcao[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Funcao | null>(null);
const [nome, setNome] = useState('');
const [descricao, setDescricao] = useState('');
const [permissoes, setPermissoes] = useState<string[]>([]);
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 (!window.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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs text-gray-400 font-bold uppercase">{funcoes.length} FUNÇÃO(ÕES)</p>
{isOwner && (
<button onClick={openAdd}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-blue-700 transition-all shadow-sm">
<Plus size={15} /> <span className="hidden sm:inline">NOVA FUNÇÃO</span>
</button>
)}
</div>
{loading ? (
<div className="flex items-center justify-center h-32 text-gray-400 text-sm font-bold uppercase">CARREGANDO...</div>
) : funcoes.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-gray-400 gap-2">
<Briefcase size={28} />
<p className="text-xs font-bold uppercase">NENHUMA FUNÇÃO CADASTRADA</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{funcoes.map(f => (
<div key={f.id} className="flex items-center gap-3 p-4 bg-white rounded-xl border border-gray-100 hover:border-blue-200 transition-all group">
<div className="w-9 h-9 rounded-xl bg-emerald-50 flex items-center justify-center flex-shrink-0">
<Briefcase size={16} className="text-emerald-600" />
</div>
<div className="flex-1 min-w-0">
<p className="font-black text-sm text-gray-800 truncate">{f.nome}</p>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
<span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-600 uppercase">NÍVEL {typeof f.nivel === 'number' ? f.nivel : 1}</span>
<span className="text-[9px] font-bold px-2 py-0.5 rounded-full bg-gray-100 text-gray-500 uppercase">
{Array.isArray(f.permissoes) && f.permissoes.length >= TODAS_PAGINAS.length ? 'TODAS AS PÁGINAS' : `${(f.permissoes || []).length} PÁGINA(S)`}
</span>
</div>
{f.descricao && <p className="text-[11px] text-gray-400 truncate mt-1">{f.descricao}</p>}
</div>
{isOwner && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => openEdit(f)} className="p-2 hover:bg-blue-50 rounded-lg transition-colors text-blue-500"><Edit2 size={15} /></button>
<button onClick={() => handleDelete(f)} className="p-2 hover:bg-red-50 rounded-lg transition-colors text-red-500"><Trash2 size={15} /></button>
</div>
)}
</div>
))}
</div>
)}
{showForm && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h3 className="font-black text-sm uppercase text-gray-800">{editing ? 'EDITAR FUNÇÃO' : 'NOVA FUNÇÃO'}</h3>
<button onClick={() => setShowForm(false)} className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"><X size={18} /></button>
</div>
<form onSubmit={handleSave} className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="sm:col-span-2">
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">NOME *</label>
<input type="text" required value={nome} onChange={e => 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-blue-400" />
</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">NÍVEL HIERÁRQUICO</label>
<input type="number" min={1} max={99} value={nivel} onChange={e => 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-blue-400" />
</div>
</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">DESCRIÇÃO</label>
<input type="text" value={descricao} onChange={e => 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-blue-400" />
<p className="text-[10px] text-gray-400 mt-1.5 leading-snug">Quanto MAIOR o nível, mais alto na hierarquia. Um membro gerencia/edita/remove quem tem nível <b>inferior</b> ao seu.</p>
</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1.5">MODELOS PRONTOS</label>
<div className="flex flex-wrap gap-2">
{TEMPLATES_CARGO.map(t => (
<button key={t.nome} type="button" onClick={() => aplicarTemplate(t)}
className="px-3 py-1.5 border border-gray-200 rounded-lg text-[10px] font-black uppercase text-gray-600 hover:border-blue-300 hover:bg-blue-50 transition-colors">
{t.nome}
</button>
))}
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-[10px] font-black text-gray-500 uppercase">PÁGINAS LIBERADAS ({permissoes.length})</label>
<button type="button" onClick={() => setPermissoes(permissoes.length >= TODAS_PAGINAS.length ? [] : [...TODAS_PAGINAS])}
className="text-[10px] font-black uppercase text-blue-600 hover:underline">
{permissoes.length >= TODAS_PAGINAS.length ? 'LIMPAR TUDO' : 'MARCAR TODAS'}
</button>
</div>
<p className="text-[10px] text-gray-400 mb-3">Minhas Clínicas, Notificações e Configurações ficam sempre disponíveis.</p>
<div className="space-y-3">
{PAGINAS_RBAC.map(g => {
const keys = g.itens.map(i => i.key);
const allOn = keys.every(k => permissoes.includes(k));
return (
<div key={g.grupo} className="border border-gray-100 rounded-xl p-3">
<button type="button" onClick={() => toggleGrupo(keys)}
className="flex items-center justify-between w-full mb-2">
<span className="text-[10px] font-black text-gray-700 uppercase">{g.grupo}</span>
<span className={`text-[9px] font-black uppercase px-2 py-0.5 rounded-full ${allOn ? 'bg-emerald-50 text-emerald-600' : 'bg-gray-100 text-gray-400'}`}>{allOn ? 'TODAS' : 'ALTERNAR'}</span>
</button>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{g.itens.map(i => {
const on = permissoes.includes(i.key);
return (
<button key={i.key} type="button" onClick={() => togglePerm(i.key)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-[11px] font-bold uppercase text-left transition-colors ${on ? 'bg-blue-50 text-blue-700 border border-blue-200' : 'bg-gray-50 text-gray-500 border border-transparent hover:bg-gray-100'}`}>
<span className={`w-4 h-4 rounded flex items-center justify-center flex-shrink-0 ${on ? 'bg-blue-600 text-white' : 'bg-white border border-gray-300'}`}>{on && <Check size={11} />}</span>
{i.label}
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
<div className="flex gap-3 pt-1">
<button type="button" onClick={() => setShowForm(false)}
className="flex-1 py-2.5 border border-gray-200 rounded-xl text-[11px] font-black uppercase hover:bg-gray-50 transition-colors">
CANCELAR
</button>
<button type="submit" disabled={saving}
className="flex-1 py-2.5 bg-blue-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-blue-700 transition-colors disabled:opacity-50">
{saving ? 'SALVANDO...' : 'SALVAR'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
// ─── View Principal ───────────────────────────────────────────────
export const GestaoEquipeView: React.FC = () => {
const [tab, setTab] = useState<Tab>('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: <Users size={15} /> },
{ id: 'setores', label: 'SETORES', icon: <Building size={15} /> },
{ id: 'funcoes', label: 'FUNÇÕES', icon: <Briefcase size={15} /> },
];
return (
<div className="space-y-6">
<PageHeader title="GESTÃO DE EQUIPE">
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 rounded-xl border border-blue-100">
<Building size={14} className="text-blue-500" />
<span className="text-[11px] font-black text-blue-700 uppercase">{clinicaNome}</span>
</div>
</PageHeader>
<p className="text-xs text-gray-400 font-bold uppercase -mt-4">
OS DADOS ABAIXO SÃO EXCLUSIVOS DESTA EMPRESA AO TROCAR DE EMPRESA OS DADOS MUDAM AUTOMATICAMENTE.
</p>
{/* Tabs */}
<div className="flex gap-1 p-1 bg-gray-100 rounded-xl w-fit">
{tabs.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-[11px] font-black uppercase transition-all ${
tab === t.id ? 'bg-white text-gray-800 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}>
{t.icon} {t.label}
</button>
))}
</div>
{/* Content */}
<div>
{tab === 'usuarios' && <UsuariosTab clinicaId={clinicaId} isOwner={isOwner} myNivel={myNivel} />}
{tab === 'setores' && <SetoresTab clinicaId={clinicaId} isOwner={isOwner} />}
{tab === 'funcoes' && <FuncoesTab clinicaId={clinicaId} isOwner={isOwner} />}
</div>
</div>
);
};