feat: implement secure multi-tenancy, RBAC, and premium dark mode
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m54s

- Enforced tenant isolation and Role-Based Access Control across all API routes

- Implemented secure profile avatar upload using multer and UUIDs

- Redesigned UI with a premium "Onyx & Gold" Charcoal dark mode

- Added Funnel Stage and Origin filters to Dashboard and User Detail pages

- Replaced "Referral" with "Indicação" across the platform and database

- Optimized Dockerfile and local environment setup for reliable deployments

- Fixed frontend syntax errors and improved KPI/Chart visualizations
This commit is contained in:
Cauê Faleiros
2026-03-03 17:16:55 -03:00
parent b7e73fce3d
commit 20bdf510fd
32 changed files with 2810 additions and 1140 deletions

View File

@@ -20,11 +20,9 @@ export const SuperAdmin: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
// Sorting State
const [sortKey, setSortKey] = useState<keyof Tenant>('created_at');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
// Load Tenants from API
const loadTenants = async () => {
setLoading(true);
const data = await getTenants();
@@ -36,16 +34,12 @@ export const SuperAdmin: React.FC = () => {
loadTenants();
}, []);
// --- Metrics ---
const totalTenants = tenants.length;
const totalUsersGlobal = tenants.reduce((acc, t) => acc + (t.user_count || 0), 0);
const totalAttendancesGlobal = tenants.reduce((acc, t) => acc + (t.attendance_count || 0), 0);
// --- Data Filtering & Sorting ---
const filteredTenants = useMemo(() => {
let data = tenants;
// Search
if (searchQuery) {
const q = searchQuery.toLowerCase();
data = data.filter(t =>
@@ -54,27 +48,20 @@ export const SuperAdmin: React.FC = () => {
t.slug?.toLowerCase().includes(q)
);
}
// Tenant Filter (Select)
if (selectedTenantId !== 'all') {
data = data.filter(t => t.id === selectedTenantId);
}
// Sort
return [...data].sort((a, b) => {
const aVal = a[sortKey];
const bVal = b[sortKey];
if (aVal === undefined) return 1;
if (bVal === undefined) return -1;
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
}, [tenants, searchQuery, selectedTenantId, sortKey, sortDirection]);
// --- Handlers ---
const handleSort = (key: keyof Tenant) => {
if (sortKey === key) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
@@ -98,286 +85,162 @@ export const SuperAdmin: React.FC = () => {
const handleSaveTenant = async (e: React.FormEvent) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const name = (form.elements.namedItem('name') as HTMLInputElement).value;
const slug = (form.elements.namedItem('slug') as HTMLInputElement).value;
const admin_email = (form.elements.namedItem('admin_email') as HTMLInputElement).value;
const status = (form.elements.namedItem('status') as HTMLSelectElement).value;
const success = await createTenant({ name, slug, admin_email, status });
if (success) {
setIsModalOpen(false);
setEditingTenant(null);
loadTenants(); // Reload list
loadTenants();
alert('Organização salva com sucesso!');
} else {
alert('Erro ao salvar organização. Verifique o console.');
alert('Erro ao salvar organização.');
}
};
// --- Helper Components ---
const SortIcon = ({ column }: { column: keyof Tenant }) => {
if (sortKey !== column) return <ChevronsUpDown size={14} className="text-slate-300" />;
return sortDirection === 'asc' ? <ChevronUp size={14} className="text-blue-500" /> : <ChevronDown size={14} className="text-blue-500" />;
if (sortKey !== column) return <ChevronsUpDown size={14} className="text-zinc-300 dark:text-dark-muted" />;
return sortDirection === 'asc' ? <ChevronUp size={14} className="text-brand-yellow" /> : <ChevronDown size={14} className="text-brand-yellow" />;
};
const StatusBadge = ({ status }: { status?: string }) => {
const styles = {
active: 'bg-green-100 text-green-700 border-green-200',
inactive: 'bg-slate-100 text-slate-700 border-slate-200',
trial: 'bg-purple-100 text-purple-700 border-purple-200',
active: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800',
inactive: 'bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-dark-input dark:text-dark-muted dark:border-dark-border',
trial: 'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:border-purple-800',
};
const style = styles[status as keyof typeof styles] || styles.inactive;
let label = status || 'Desconhecido';
if (status === 'active') label = 'Ativo';
if (status === 'inactive') label = 'Inativo';
if (status === 'trial') label = 'Teste';
return (
<span className={`px-2.5 py-0.5 rounded-full text-xs font-semibold border capitalize ${style}`}>
{label}
</span>
);
let label = status === 'active' ? 'Ativo' : status === 'inactive' ? 'Inativo' : status === 'trial' ? 'Teste' : 'Desconhecido';
return <span className={`px-2.5 py-0.5 rounded-full text-xs font-semibold border capitalize ${style}`}>{label}</span>;
};
return (
<div className="space-y-8 max-w-7xl mx-auto pb-8">
{/* Header */}
<div className="space-y-8 max-w-7xl mx-auto pb-8 transition-colors duration-300">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Painel Super Admin</h1>
<p className="text-slate-500 text-sm">Gerencie organizações, visualize estatísticas globais e saúde do sistema.</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => { setEditingTenant(null); setIsModalOpen(true); }}
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors shadow-sm"
>
<Plus size={16} /> Adicionar Organização
</button>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 tracking-tight">Painel Super Admin</h1>
<p className="text-zinc-500 dark:text-dark-muted text-sm">Gerencie organizações e visualize estatísticas globais.</p>
</div>
<button onClick={() => { setEditingTenant(null); setIsModalOpen(true); }} className="bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 px-4 py-2 rounded-lg text-sm font-bold hover:opacity-90 transition-all shadow-sm">
<Plus size={16} /> Adicionar Organização
</button>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<KPICard title="Total de Organizações" value={totalTenants} icon={Building2} colorClass="text-blue-600" trend="up" trendValue="+1 este mês" />
<KPICard title="Total de Usuários" value={totalUsersGlobal} icon={Users} colorClass="text-purple-600" subValue="Em todas as organizações" />
<KPICard title="Total de Atendimentos" value={totalAttendancesGlobal.toLocaleString()} icon={MessageSquare} colorClass="text-green-600" trend="up" trendValue="+12%" />
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<KPICard
title="Total de Organizações"
value={totalTenants}
icon={Building2}
colorClass="text-blue-600"
trend="up"
trendValue="+1 este mês"
/>
<KPICard
title="Total de Usuários"
value={totalUsersGlobal}
icon={Users}
colorClass="text-purple-600"
subValue="Em todas as organizações"
/>
<KPICard
title="Total de Atendimentos"
value={totalAttendancesGlobal.toLocaleString()}
icon={MessageSquare}
colorClass="text-green-600"
trend="up"
trendValue="+12%"
/>
</div>
{/* Filters & Search */}
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex flex-col md:flex-row gap-4 justify-between items-center">
<div className="bg-white dark:bg-dark-card p-4 rounded-xl border border-zinc-200 dark:border-dark-border shadow-sm flex flex-col md:flex-row gap-4 justify-between items-center transition-colors">
<div className="flex items-center gap-3 w-full md:w-auto">
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
<div className="h-8 w-px bg-slate-200 hidden md:block" />
<select
className="bg-slate-50 border border-slate-200 px-3 py-2 rounded-lg text-sm text-slate-700 outline-none focus:ring-2 focus:ring-slate-200 cursor-pointer hover:border-slate-300 w-full md:w-48"
value={selectedTenantId}
onChange={(e) => setSelectedTenantId(e.target.value)}
>
<div className="h-8 w-px bg-zinc-200 dark:bg-dark-border hidden md:block" />
<select className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-dark-border w-full md:w-48 transition-all" value={selectedTenantId} onChange={(e) => setSelectedTenantId(e.target.value)}>
<option value="all">Todas Organizações</option>
{tenants.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
<div className="relative w-full md:w-64">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="Buscar organizações..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm outline-none focus:ring-2 focus:ring-blue-100 transition-all"
/>
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 dark:text-dark-muted" />
<input type="text" placeholder="Buscar organizações..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="w-full pl-9 pr-4 py-2 bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border rounded-lg text-sm text-zinc-900 dark:text-zinc-100 outline-none focus:ring-2 focus:ring-brand-yellow/20 transition-all" />
</div>
</div>
{/* Tenants Table */}
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-xl shadow-sm overflow-hidden transition-colors">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50/50 text-slate-500 text-xs uppercase tracking-wider border-b border-slate-100">
<th className="px-6 py-4 font-semibold cursor-pointer hover:bg-slate-50 select-none" onClick={() => handleSort('name')}>
<tr className="bg-zinc-50/50 dark:bg-dark-bg/50 text-zinc-500 dark:text-dark-muted text-xs uppercase tracking-wider border-b border-zinc-100 dark:border-dark-border">
<th className="px-6 py-4 font-semibold cursor-pointer hover:bg-zinc-50 dark:hover:bg-dark-border select-none" onClick={() => handleSort('name')}>
<div className="flex items-center gap-2">Organização <SortIcon column="name" /></div>
</th>
<th className="px-6 py-4 font-semibold">Slug</th>
<th className="px-6 py-4 font-semibold cursor-pointer hover:bg-slate-50 select-none" onClick={() => handleSort('status')}>
<th className="px-6 py-4 font-semibold cursor-pointer hover:bg-zinc-50 dark:hover:bg-dark-border select-none" onClick={() => handleSort('status')}>
<div className="flex items-center gap-2">Status <SortIcon column="status" /></div>
</th>
<th className="px-6 py-4 font-semibold text-center cursor-pointer hover:bg-slate-50 select-none" onClick={() => handleSort('user_count')}>
<th className="px-6 py-4 font-semibold text-center cursor-pointer hover:bg-zinc-50 dark:hover:bg-dark-border select-none" onClick={() => handleSort('user_count')}>
<div className="flex items-center justify-center gap-2">Usuários <SortIcon column="user_count" /></div>
</th>
<th className="px-6 py-4 font-semibold text-center cursor-pointer hover:bg-slate-50 select-none" onClick={() => handleSort('attendance_count')}>
<th className="px-6 py-4 font-semibold text-center cursor-pointer hover:bg-zinc-50 dark:hover:bg-dark-border select-none" onClick={() => handleSort('attendance_count')}>
<div className="flex items-center justify-center gap-2">Atendimentos <SortIcon column="attendance_count" /></div>
</th>
<th className="px-6 py-4 font-semibold text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-sm">
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border text-sm">
{filteredTenants.map((tenant) => (
<tr key={tenant.id} className="hover:bg-slate-50/50 transition-colors group">
<tr key={tenant.id} className="hover:bg-zinc-50/50 dark:hover:bg-zinc-800/50 transition-colors group">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-slate-100 flex items-center justify-center text-slate-400 font-bold border border-slate-200">
<div className="w-10 h-10 rounded-lg bg-zinc-100 dark:bg-dark-bg flex items-center justify-center text-zinc-400 dark:text-dark-muted font-bold border border-zinc-200 dark:border-dark-border">
{tenant.logo_url ? <img src={tenant.logo_url} className="w-full h-full object-cover rounded-lg" alt="" /> : <Building2 size={20} />}
</div>
<div>
<div className="font-semibold text-slate-900">{tenant.name}</div>
<div className="text-xs text-slate-500">{tenant.admin_email}</div>
<div className="font-semibold text-zinc-900 dark:text-zinc-100">{tenant.name}</div>
<div className="text-xs text-zinc-500 dark:text-dark-muted">{tenant.admin_email}</div>
</div>
</div>
</td>
<td className="px-6 py-4 text-slate-600 font-mono text-xs">
{tenant.slug}
</td>
<td className="px-6 py-4">
<StatusBadge status={tenant.status} />
</td>
<td className="px-6 py-4 text-center font-medium text-slate-700">
{tenant.user_count}
</td>
<td className="px-6 py-4 text-center font-medium text-slate-700">
{tenant.attendance_count?.toLocaleString()}
</td>
<td className="px-6 py-4 text-zinc-600 dark:text-dark-muted font-mono text-xs">{tenant.slug}</td>
<td className="px-6 py-4"><StatusBadge status={tenant.status} /></td>
<td className="px-6 py-4 text-center font-medium text-zinc-700 dark:text-zinc-300">{tenant.user_count}</td>
<td className="px-6 py-4 text-center font-medium text-zinc-700 dark:text-zinc-300">{tenant.attendance_count?.toLocaleString()}</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleEdit(tenant)}
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Editar Organização"
>
<Edit size={16} />
</button>
<button
onClick={() => handleDelete(tenant.id)}
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Excluir Organização"
>
<Trash2 size={16} />
</button>
<button onClick={() => handleEdit(tenant)} className="p-2 text-zinc-400 hover:text-brand-yellow hover:bg-zinc-50 dark:hover:bg-dark-bg rounded-lg transition-colors"><Edit size={16} /></button>
<button onClick={() => handleDelete(tenant.id)} className="p-2 text-zinc-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"><Trash2 size={16} /></button>
</div>
</td>
</tr>
))}
{filteredTenants.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-slate-400 italic">Nenhuma organização encontrada com os filtros atuais.</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Simple Pagination Footer */}
<div className="px-6 py-4 border-t border-slate-100 bg-slate-50/30 text-xs text-slate-500 flex justify-between items-center">
<div className="px-6 py-4 border-t border-zinc-100 dark:border-dark-border bg-zinc-50/30 dark:bg-dark-bg/30 text-xs text-zinc-500 dark:text-dark-muted flex justify-between items-center transition-colors">
<span>Mostrando {filteredTenants.length} de {tenants.length} organizações</span>
<div className="flex gap-2">
<button disabled className="px-3 py-1 border rounded bg-white disabled:opacity-50">Ant</button>
<button disabled className="px-3 py-1 border rounded bg-white disabled:opacity-50">Próx</button>
<button disabled className="px-3 py-1 border dark:border-dark-border rounded bg-white dark:bg-dark-card disabled:opacity-50">Ant</button>
<button disabled className="px-3 py-1 border dark:border-dark-border rounded bg-white dark:bg-dark-card disabled:opacity-50">Próx</button>
</div>
</div>
</div>
{/* Add/Edit Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
<h3 className="font-bold text-slate-900">{editingTenant ? 'Editar Organização' : 'Adicionar Nova Organização'}</h3>
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600">
<X size={20} />
</button>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm">
<div className="bg-white dark:bg-dark-card rounded-xl shadow-xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in duration-200 border border-transparent dark:border-dark-border transition-colors">
<div className="px-6 py-4 border-b border-zinc-100 dark:border-dark-border flex justify-between items-center bg-zinc-50/50 dark:bg-dark-bg/50">
<h3 className="font-bold text-zinc-900 dark:text-zinc-50">{editingTenant ? 'Editar Organização' : 'Adicionar Nova Organização'}</h3>
<button onClick={() => setIsModalOpen(false)} className="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"><X size={20} /></button>
</div>
<form onSubmit={handleSaveTenant} className="p-6 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Nome da Organização</label>
<input
type="text"
name="name"
defaultValue={editingTenant?.name}
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-100 outline-none"
placeholder="ex. Acme Corp"
required
/>
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Nome da Organização</label>
<input type="text" name="name" defaultValue={editingTenant?.name} className="w-full px-3 py-2 bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border rounded-lg text-sm text-zinc-900 dark:text-zinc-100 focus:ring-2 focus:ring-brand-yellow/20 outline-none transition-all" required />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Slug</label>
<input
type="text"
name="slug"
defaultValue={editingTenant?.slug}
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-100 outline-none"
placeholder="ex. acme-corp"
/>
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Slug</label>
<input type="text" name="slug" defaultValue={editingTenant?.slug} className="w-full px-3 py-2 bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border rounded-lg text-sm text-zinc-900 dark:text-zinc-100 focus:ring-2 focus:ring-brand-yellow/20 outline-none transition-all" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Status</label>
<select
name="status"
defaultValue={editingTenant?.status || 'active'}
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-100 outline-none"
>
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Status</label>
<select name="status" defaultValue={editingTenant?.status || 'active'} className="w-full px-3 py-2 bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border rounded-lg text-sm text-zinc-900 dark:text-zinc-100 outline-none transition-all">
<option value="active">Ativo</option>
<option value="inactive">Inativo</option>
<option value="trial">Teste</option>
</select>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">E-mail do Admin</label>
<input
type="email"
name="admin_email"
defaultValue={editingTenant?.admin_email}
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-100 outline-none"
placeholder="admin@empresa.com"
required
/>
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">E-mail do Admin</label>
<input type="email" name="admin_email" defaultValue={editingTenant?.admin_email} className="w-full px-3 py-2 bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border rounded-lg text-sm text-zinc-900 dark:text-zinc-100 focus:ring-2 focus:ring-brand-yellow/20 outline-none transition-all" required />
</div>
<div className="pt-4 flex justify-end gap-3">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg text-sm font-medium transition-colors"
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors shadow-sm"
>
{editingTenant ? 'Salvar Alterações' : 'Criar Organização'}
</button>
<div className="pt-4 flex justify-end gap-3 border-t dark:border-dark-border mt-6">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-4 py-2 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-dark-border rounded-lg text-sm font-medium transition-colors">Cancelar</button>
<button type="submit" className="px-4 py-2 bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 rounded-lg text-sm font-bold hover:opacity-90 transition-all shadow-sm">{editingTenant ? 'Salvar Alterações' : 'Criar Organização'}</button>
</div>
</form>
</div>
@@ -385,4 +248,4 @@ export const SuperAdmin: React.FC = () => {
)}
</div>
);
};
};