feat: Implement backend API and basic frontend structure
Adds initial backend API endpoints for fetching users and attendances, including basic filtering. Sets up the frontend routing with a layout component and includes placeholder pages for dashboard, users, and login. Refactors the README for local development setup.
This commit is contained in:
362
pages/SuperAdmin.tsx
Normal file
362
pages/SuperAdmin.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Building2, Users, MessageSquare, Plus, Search, MoreHorizontal,
|
||||
Edit, Trash2, Shield, Calendar, ChevronDown, ChevronUp, ChevronsUpDown, X
|
||||
} from 'lucide-react';
|
||||
import { TENANTS, MOCK_ATTENDANCES, USERS } from '../constants';
|
||||
import { Tenant } from '../types';
|
||||
import { DateRangePicker } from '../components/DateRangePicker';
|
||||
import { KPICard } from '../components/KPICard';
|
||||
|
||||
// Reusing KPICard but simplifying logic for this view if needed
|
||||
// We can use the existing KPICard component.
|
||||
|
||||
export const SuperAdmin: React.FC = () => {
|
||||
const [dateRange, setDateRange] = useState({
|
||||
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||
end: new Date()
|
||||
});
|
||||
const [selectedTenantId, setSelectedTenantId] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [tenants, setTenants] = useState<Tenant[]>(TENANTS);
|
||||
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');
|
||||
|
||||
// --- Metrics ---
|
||||
const totalTenants = tenants.length;
|
||||
// Mock aggregation for demo
|
||||
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 =>
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
t.admin_email?.toLowerCase().includes(q) ||
|
||||
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');
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (tenant: Tenant) => {
|
||||
setEditingTenant(tenant);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm('Tem certeza que deseja excluir esta organização? Esta ação não pode ser desfeita.')) {
|
||||
setTenants(prev => prev.filter(t => t.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTenant = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Logic to save (mock)
|
||||
setIsModalOpen(false);
|
||||
setEditingTenant(null);
|
||||
alert('Organização salva com sucesso (Mock)');
|
||||
};
|
||||
|
||||
// --- 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" />;
|
||||
};
|
||||
|
||||
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',
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-7xl mx-auto pb-8">
|
||||
{/* Header */}
|
||||
<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>
|
||||
</div>
|
||||
</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="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)}
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tenants Table */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<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')}>
|
||||
<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')}>
|
||||
<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')}>
|
||||
<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')}>
|
||||
<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">
|
||||
{filteredTenants.map((tenant) => (
|
||||
<tr key={tenant.id} className="hover:bg-slate-50/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">
|
||||
{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>
|
||||
</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-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>
|
||||
</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">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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"
|
||||
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
|
||||
/>
|
||||
</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"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">Status</label>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
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
|
||||
/>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user