b67fa15c55
Nova aba no super admin (/superadmin/secretaria): tabela com quem ligou/desligou a Secretária de cada número, quando e o MOTIVO do desligamento. Endpoint /api/superadmin/secretaria-desligamentos (superadminGuard) busca o log global no motor (resolve um e-mail de dono pareado; log é global). Item no Sidebar + rotas. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1123 lines
70 KiB
TypeScript
1123 lines
70 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||
import {
|
||
Users, Building2, Activity, DollarSign, ShieldCheck, RefreshCw,
|
||
CheckCircle2, XCircle, Clock, Search, ChevronLeft, ChevronRight,
|
||
TrendingUp, Stethoscope, FlaskConical, User, ToggleLeft, ToggleRight,
|
||
Save, AlertTriangle, Wifi, WifiOff, Briefcase, Trash2, Bell, Send,
|
||
MapPin, Phone, MoreVertical, X, ArrowLeft, UserX, Sparkles, DoorOpen, UserSearch, Power
|
||
} from 'lucide-react';
|
||
|
||
const apiFetch = (url: string, opts: RequestInit = {}) => {
|
||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||
return fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...opts.headers } });
|
||
};
|
||
|
||
const ROLE_LABELS: Record<string, { label: string; color: string; icon: React.ElementType }> = {
|
||
donoconsultorio: { label: 'Consultório', color: 'emerald', icon: Briefcase },
|
||
donoclinica: { label: 'Clínica', color: 'blue', icon: Building2 },
|
||
donosala: { label: 'Dono de Sala', color: 'teal', icon: DoorOpen },
|
||
dentista: { label: 'Dentista', color: 'indigo', icon: Stethoscope },
|
||
protetico: { label: 'Protético', color: 'violet', icon: FlaskConical },
|
||
paciente: { label: 'Paciente', color: 'gray', icon: User },
|
||
superadmin: { label: 'Super Admin', color: 'red', icon: ShieldCheck },
|
||
};
|
||
|
||
const RoleBadge: React.FC<{ role: string }> = ({ role }) => {
|
||
const cfg = ROLE_LABELS[role] || { label: role, color: 'gray', icon: User };
|
||
const Icon = cfg.icon;
|
||
const colors: Record<string, string> = {
|
||
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||
blue: 'bg-blue-50 text-blue-700 border-blue-200',
|
||
indigo: 'bg-indigo-50 text-indigo-700 border-indigo-200',
|
||
violet: 'bg-violet-50 text-violet-700 border-violet-200',
|
||
gray: 'bg-gray-100 text-gray-600 border-gray-200',
|
||
red: 'bg-red-50 text-red-700 border-red-200',
|
||
};
|
||
return (
|
||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-black uppercase border ${colors[cfg.color]}`}>
|
||
<Icon size={10} />{cfg.label}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
const StatusBadge: React.FC<{ ok: boolean }> = ({ ok }) => ok
|
||
? <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-black uppercase bg-green-50 text-green-700 border border-green-200"><CheckCircle2 size={10} />Ativo</span>
|
||
: <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-black uppercase bg-red-50 text-red-600 border border-red-200"><XCircle size={10} />Suspenso</span>;
|
||
|
||
const ASSBadge: React.FC<{ status?: string; validade?: string }> = ({ status, validade }) => {
|
||
const expired = validade ? new Date(validade) < new Date() : true;
|
||
if (status === 'pago' && !expired)
|
||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-black bg-emerald-50 text-emerald-700 border border-emerald-200"><CheckCircle2 size={10} />Pago</span>;
|
||
if (status === 'pago' && expired)
|
||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-black bg-orange-50 text-orange-700 border border-orange-200"><AlertTriangle size={10} />Vencido</span>;
|
||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-black bg-yellow-50 text-yellow-700 border border-yellow-200"><Clock size={10} />Pendente</span>;
|
||
};
|
||
|
||
const fmt = (d: string | null | undefined) => d ? new Date(d).toLocaleDateString('pt-BR', { day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit' }) : '—';
|
||
const fmtDate = (d: string | null | undefined) => d ? new Date(d).toLocaleDateString('pt-BR') : '—';
|
||
|
||
/* ─── KPI Card ─── */
|
||
const KPI: React.FC<{ icon: React.ElementType; label: string; value: string | number; sub?: string; color?: string }> = ({ icon: Icon, label, value, sub, color = 'blue' }) => {
|
||
const bg: Record<string, string> = { blue: 'bg-blue-600', green: 'bg-emerald-500', orange: 'bg-orange-500', violet: 'bg-violet-600' };
|
||
return (
|
||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5 flex items-start gap-3 sm:gap-4">
|
||
<div className={`w-10 h-10 sm:w-11 sm:h-11 ${bg[color]} rounded-xl flex items-center justify-center shrink-0`}>
|
||
<Icon size={18} className="text-white" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-[9px] sm:text-[10px] font-black text-gray-400 uppercase tracking-widest leading-tight">{label}</p>
|
||
<p className="text-xl sm:text-2xl font-black text-gray-900 leading-tight">{value}</p>
|
||
{sub && <p className="text-[10px] sm:text-xs text-gray-400 font-medium mt-0.5 leading-tight">{sub}</p>}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
type SuperAdminTab = 'overview' | 'users' | 'clinicas' | 'logs' | 'financeiro' | 'notificacoes' | 'secretaria';
|
||
|
||
/* ═══════════════════════════════════════════════════ MAIN VIEW */
|
||
export const SuperAdminView: React.FC<{ tab?: SuperAdminTab }> = ({ tab = 'overview' }) => {
|
||
return (
|
||
<div className="space-y-6">
|
||
{tab === 'overview' && <TabOverview />}
|
||
{tab === 'users' && <TabUsers />}
|
||
{tab === 'clinicas' && <TabClinicas />}
|
||
{tab === 'logs' && <TabLogs />}
|
||
{tab === 'financeiro' && <TabFinanceiro />}
|
||
{tab === 'notificacoes' && <TabNotificacoes />}
|
||
{tab === 'secretaria' && <TabSecretaria />}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/* ═══════════════════════════════════════════════════ TAB SECRETÁRIA (liga/desliga por sessão) */
|
||
const TabSecretaria: React.FC = () => {
|
||
const [log, setLog] = useState<any[]>([]);
|
||
const [configurado, setConfigurado] = useState(true);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
const load = useCallback(() => {
|
||
setLoading(true);
|
||
apiFetch('/api/superadmin/secretaria-desligamentos')
|
||
.then(r => r.json())
|
||
.then(d => { setLog(Array.isArray(d.log) ? d.log : []); setConfigurado(d.configurado !== false); })
|
||
.catch(() => {})
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const totalOff = log.filter(l => !l.enabled).length;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||
<div>
|
||
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tight flex items-center gap-2"><Power size={18} className="text-red-600" /> Secretária IA — liga/desliga por sessão</h2>
|
||
<p className="text-xs text-gray-400 font-medium">Quem ligou/desligou a Secretária de cada número, e o motivo do desligamento. {totalOff > 0 && `${totalOff} desligamento(s).`}</p>
|
||
</div>
|
||
<button onClick={load} className="p-2 rounded-xl border border-gray-200 text-gray-400 hover:text-gray-700"><RefreshCw size={16} /></button>
|
||
</div>
|
||
{!configurado ? (
|
||
<div className="bg-amber-50 text-amber-700 text-sm rounded-2xl p-4 flex items-center gap-2"><AlertTriangle size={16} /> Integração com o motor da Secretária não está configurada neste ambiente.</div>
|
||
) : loading ? (
|
||
<div className="text-center text-gray-400 py-16">Carregando…</div>
|
||
) : log.length === 0 ? (
|
||
<div className="text-center text-gray-400 py-16">Nenhum registro de liga/desliga ainda.</div>
|
||
) : (
|
||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
|
||
<table className="w-full text-sm min-w-[640px]">
|
||
<thead className="bg-gray-50 text-[10px] font-black text-gray-400 uppercase tracking-widest">
|
||
<tr>
|
||
<th className="text-left px-4 py-3">Ação</th>
|
||
<th className="text-left px-4 py-3">Número</th>
|
||
<th className="text-left px-4 py-3">Quem</th>
|
||
<th className="text-left px-4 py-3">Quando</th>
|
||
<th className="text-left px-4 py-3">Motivo</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{log.map((l, i) => (
|
||
<tr key={i} className={`border-t border-gray-50 ${!l.enabled ? 'bg-red-50/30' : ''}`}>
|
||
<td className="px-4 py-3">
|
||
{l.enabled
|
||
? <span className="inline-flex items-center gap-1 text-emerald-700 text-[11px] font-black uppercase"><ToggleRight size={14} /> Ligou</span>
|
||
: <span className="inline-flex items-center gap-1 text-red-600 text-[11px] font-black uppercase"><ToggleLeft size={14} /> Desligou</span>}
|
||
</td>
|
||
<td className="px-4 py-3 text-gray-700 font-bold whitespace-nowrap">{l.phone || l.name || l.instance_id}</td>
|
||
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{l.by || '—'}</td>
|
||
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{fmt(l.at)}</td>
|
||
<td className="px-4 py-3 text-gray-600">{l.reason || <span className="text-gray-300">—</span>}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/* ═══════════════════════════════════════════════════ TAB OVERVIEW */
|
||
const TabOverview: React.FC = () => {
|
||
const [stats, setStats] = useState<any>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
apiFetch('/api/superadmin/stats')
|
||
.then(r => r.ok ? r.json() : null)
|
||
.then(d => { if (d?.usuarios) setStats(d); })
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
if (loading) return <Spinner />;
|
||
if (!stats?.usuarios) return <ErrorMsg />;
|
||
|
||
const totalUsers = stats.usuarios.reduce((s: number, r: any) => s + parseInt(r.total), 0);
|
||
|
||
return (
|
||
<div className="space-y-5 sm:space-y-6">
|
||
<SectionTitle>Resumo do Sistema</SectionTitle>
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||
<KPI icon={Users} label="Total Usuários" value={totalUsers} color="blue" />
|
||
<KPI icon={Building2} label="Clínicas" value={stats.clinicas} color="violet" />
|
||
<KPI icon={Activity} label="Logins Hoje" value={stats.logins_hoje} color="green" />
|
||
<KPI icon={AlertTriangle} label="Sem Assinatura" value={stats.sem_assinatura} color="orange" sub="aguardando" />
|
||
</div>
|
||
|
||
<SectionTitle>Usuários por Perfil</SectionTitle>
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">
|
||
{stats.usuarios.map((r: any) => {
|
||
const cfg = ROLE_LABELS[r.role] || { label: r.role, icon: User };
|
||
const Icon = cfg.icon;
|
||
return (
|
||
<div key={r.role} className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<Icon size={14} className="text-blue-600 shrink-0" />
|
||
<span className="text-[9px] sm:text-[10px] font-black text-gray-400 uppercase tracking-widest truncate">{cfg.label}</span>
|
||
</div>
|
||
<p className="text-2xl sm:text-3xl font-black text-gray-900">{r.total}</p>
|
||
{parseInt(r.suspensos) > 0 && (
|
||
<p className="text-[10px] text-red-500 font-bold mt-1">{r.suspensos} suspenso(s)</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/* ═══════════════════════════════════════════════════ TAB USERS */
|
||
const TabUsers: React.FC = () => {
|
||
const [clinicas, setClinicas] = useState<any[]>([]);
|
||
const [loadingClinicas, setLoadingClinicas] = useState(true);
|
||
const [clinicaSearch, setClinicaSearch] = useState('');
|
||
const [selectedClinica, setSelectedClinica] = useState<any | null>(null);
|
||
|
||
useEffect(() => {
|
||
apiFetch('/api/superadmin/clinicas').then(r => r.json()).then(d => {
|
||
setClinicas(Array.isArray(d) ? d : []);
|
||
}).finally(() => setLoadingClinicas(false));
|
||
}, []);
|
||
|
||
const filteredClinicas = clinicas.filter(c =>
|
||
!clinicaSearch || c.nome_fantasia?.toLowerCase().includes(clinicaSearch.toLowerCase()) ||
|
||
c.cidade?.toLowerCase().includes(clinicaSearch.toLowerCase())
|
||
);
|
||
|
||
/* ── Estágio 1: grid de clínicas ── */
|
||
if (!selectedClinica) {
|
||
return (
|
||
<div className="space-y-3 sm:space-y-4">
|
||
<div className="flex gap-2 sm:gap-3 items-center">
|
||
<div className="relative flex-1">
|
||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||
<input value={clinicaSearch} onChange={e => setClinicaSearch(e.target.value)}
|
||
className="w-full pl-9 pr-3 py-2.5 bg-white border border-gray-200 rounded-xl text-sm font-medium outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-50 transition-all"
|
||
placeholder="Buscar clínica ou consultório..." />
|
||
</div>
|
||
<button onClick={() => { setLoadingClinicas(true); apiFetch('/api/superadmin/clinicas').then(r => r.json()).then(d => setClinicas(Array.isArray(d) ? d : [])).finally(() => setLoadingClinicas(false)); }}
|
||
className="p-2.5 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition-all shrink-0">
|
||
<RefreshCw size={14} className={`text-gray-400 ${loadingClinicas ? 'animate-spin' : ''}`} />
|
||
</button>
|
||
</div>
|
||
|
||
{loadingClinicas ? <Spinner /> : (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||
{filteredClinicas.map(c => {
|
||
const isCons = c.dono_role === 'donoconsultorio';
|
||
return (
|
||
<button key={c.id} onClick={() => setSelectedClinica(c)}
|
||
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 text-left hover:border-blue-300 hover:shadow-md transition-all group">
|
||
<div className="flex items-start gap-3">
|
||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${isCons ? 'bg-emerald-50' : 'bg-blue-50'} group-hover:scale-105 transition-transform`}>
|
||
{isCons ? <Briefcase size={18} className="text-emerald-600" /> : <Building2 size={18} className="text-blue-600" />}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="font-black text-gray-900 text-sm truncate leading-tight">{c.nome_fantasia}</p>
|
||
{c.dono_nome && <p className="text-xs text-gray-400 font-medium truncate mt-0.5">{c.dono_nome}</p>}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-between mt-3">
|
||
<div className="flex items-center gap-2">
|
||
{c.cidade && (
|
||
<span className="flex items-center gap-1 text-[10px] text-gray-400 font-medium">
|
||
<MapPin size={10} />{c.cidade}{c.estado ? `/${c.estado}` : ''}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<span className={`text-[10px] font-black px-2 py-1 rounded-lg ${c.membros > 0 ? 'bg-blue-50 text-blue-600' : 'bg-gray-100 text-gray-400'}`}>
|
||
{c.membros} {Number(c.membros) === 1 ? 'membro' : 'membros'}
|
||
</span>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
|
||
{/* Card: sem vínculo */}
|
||
<button onClick={() => setSelectedClinica({ id: 'none', nome_fantasia: 'Sem vínculo', _none: true })}
|
||
className="bg-white rounded-2xl border border-dashed border-gray-200 p-4 text-left hover:border-gray-400 hover:shadow-md transition-all group">
|
||
<div className="flex items-start gap-3">
|
||
<div className="w-10 h-10 rounded-xl bg-gray-100 flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform">
|
||
<UserX size={18} className="text-gray-400" />
|
||
</div>
|
||
<div>
|
||
<p className="font-black text-gray-600 text-sm">Sem vínculo</p>
|
||
<p className="text-[10px] text-gray-400 mt-0.5">Usuários sem clínica</p>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── Estágio 2: usuários da clínica selecionada ── */
|
||
return (
|
||
<ClinicaUsuarios
|
||
clinica={selectedClinica}
|
||
onBack={() => setSelectedClinica(null)}
|
||
/>
|
||
);
|
||
};
|
||
|
||
/* ── Usuários de uma clínica específica ── */
|
||
const ClinicaUsuarios: React.FC<{ clinica: any; onBack: () => void }> = ({ clinica, onBack }) => {
|
||
const confirm = useConfirm();
|
||
const [data, setData] = useState<any[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [page, setPage] = useState(1);
|
||
const [search, setSearch] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||
const [assinaturaModal, setAssinaturaModal] = useState<any | null>(null);
|
||
const [menuOpen, setMenuOpen] = useState<string | null>(null);
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const params = new URLSearchParams({ page: String(page), pageSize: String(PAGE_SIZE), clinicaId: clinica.id });
|
||
if (search) params.set('search', search);
|
||
const res = await apiFetch(`/api/superadmin/usuarios?${params}`);
|
||
const json = await res.json();
|
||
setData(json.data || []); setTotal(json.total || 0);
|
||
} finally { setLoading(false); }
|
||
}, [page, search, clinica.id]);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const toggleStatus = async (u: any) => {
|
||
setTogglingId(u.id); setMenuOpen(null);
|
||
await apiFetch(`/api/superadmin/usuarios/${u.id}/status`, { method: 'PUT', body: JSON.stringify({ ativo: !u.ativo }) });
|
||
setTogglingId(null);
|
||
load();
|
||
};
|
||
|
||
const deleteUser = async (u: any) => {
|
||
setMenuOpen(null);
|
||
if (!await confirm(`EXCLUIR "${u.nome}" PERMANENTEMENTE?`)) return;
|
||
await apiFetch(`/api/superadmin/usuarios/${u.id}`, { method: 'DELETE' });
|
||
load();
|
||
};
|
||
|
||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||
const isCons = clinica.dono_role === 'donoconsultorio';
|
||
|
||
return (
|
||
<div className="space-y-3 sm:space-y-4">
|
||
{/* Breadcrumb header */}
|
||
<div className="flex items-center gap-3">
|
||
<button onClick={onBack}
|
||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-white border border-gray-200 text-xs font-black uppercase text-gray-500 hover:text-gray-800 hover:bg-gray-50 transition-all shrink-0">
|
||
<ArrowLeft size={13} />
|
||
<span className="hidden sm:inline">Clínicas</span>
|
||
</button>
|
||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||
<div className={`w-8 h-8 rounded-xl flex items-center justify-center shrink-0 ${clinica._none ? 'bg-gray-100' : isCons ? 'bg-emerald-50' : 'bg-blue-50'}`}>
|
||
{clinica._none ? <UserX size={15} className="text-gray-400" />
|
||
: isCons ? <Briefcase size={15} className="text-emerald-600" />
|
||
: <Building2 size={15} className="text-blue-600" />}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="font-black text-gray-900 text-sm truncate leading-tight">{clinica.nome_fantasia}</p>
|
||
{!clinica._none && (
|
||
<p className="text-[10px] text-gray-400 font-medium leading-tight hidden sm:block">
|
||
{clinica.cidade ? `${clinica.cidade}${clinica.estado ? `/${clinica.estado}` : ''}` : ''}
|
||
{clinica.dono_nome ? ` · ${clinica.dono_nome}` : ''}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{!loading && (
|
||
<span className="shrink-0 text-[10px] font-black px-2.5 py-1.5 rounded-xl bg-blue-50 text-blue-600 border border-blue-100">
|
||
{total} {total === 1 ? 'membro' : 'membros'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Search + refresh */}
|
||
<div className="flex gap-2 sm:gap-3 items-center">
|
||
<div className="relative flex-1">
|
||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||
<input value={search} onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||
className="w-full pl-9 pr-3 py-2.5 bg-white border border-gray-200 rounded-xl text-sm font-medium outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-50 transition-all"
|
||
placeholder="Buscar por nome ou e-mail..." />
|
||
</div>
|
||
<button onClick={load} className="p-2.5 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition-all shrink-0">
|
||
<RefreshCw size={14} className={`text-gray-400 ${loading ? 'animate-spin' : ''}`} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Mobile: Cards */}
|
||
<div className="sm:hidden space-y-2">
|
||
{loading && <Spinner />}
|
||
{!loading && data.length === 0 && (
|
||
<div className="text-center py-12 text-gray-400">
|
||
<Users size={28} className="mx-auto mb-2 opacity-30" />
|
||
<p className="text-sm font-medium">Nenhum usuário encontrado</p>
|
||
</div>
|
||
)}
|
||
{data.map(u => (
|
||
<div key={u.id} className={`bg-white rounded-2xl border border-gray-100 shadow-sm p-4 ${u.ativo === false ? 'opacity-60' : ''}`}>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0 flex-1">
|
||
<p className="font-black text-gray-900 text-sm truncate">{u.nome}</p>
|
||
<p className="text-xs text-gray-400 truncate">{u.email}</p>
|
||
</div>
|
||
<div className="relative shrink-0">
|
||
<button onClick={() => setMenuOpen(menuOpen === u.id ? null : u.id)}
|
||
className="p-1.5 rounded-lg hover:bg-gray-100 transition-all">
|
||
<MoreVertical size={16} className="text-gray-400" />
|
||
</button>
|
||
{menuOpen === u.id && (
|
||
<div className="absolute right-0 top-8 bg-white rounded-xl border border-gray-100 shadow-xl z-20 min-w-40 overflow-hidden">
|
||
<button onClick={() => { setAssinaturaModal(u); setMenuOpen(null); }}
|
||
className="w-full flex items-center gap-2 px-4 py-2.5 text-xs font-black uppercase text-blue-600 hover:bg-blue-50 transition-all">
|
||
<DollarSign size={12} />Registrar Pag.
|
||
</button>
|
||
<button onClick={() => toggleStatus(u)} disabled={togglingId === u.id}
|
||
className={`w-full flex items-center gap-2 px-4 py-2.5 text-xs font-black uppercase transition-all ${u.ativo !== false ? 'text-orange-600 hover:bg-orange-50' : 'text-green-600 hover:bg-green-50'}`}>
|
||
{togglingId === u.id ? <RefreshCw size={12} className="animate-spin" /> : u.ativo !== false ? <><ToggleLeft size={12} />Suspender</> : <><ToggleRight size={12} />Ativar</>}
|
||
</button>
|
||
<button onClick={() => deleteUser(u)}
|
||
className="w-full flex items-center gap-2 px-4 py-2.5 text-xs font-black uppercase text-red-600 hover:bg-red-50 transition-all border-t border-gray-100">
|
||
<Trash2 size={12} />Excluir
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||
<RoleBadge role={u.role} />
|
||
<StatusBadge ok={u.ativo !== false} />
|
||
<ASSBadge status={u.assinatura_status} validade={u.assinatura_validade} />
|
||
</div>
|
||
{u.assinatura_validade && (
|
||
<p className="text-[10px] text-gray-400 mt-2">Validade: {fmtDate(u.assinatura_validade)}</p>
|
||
)}
|
||
{u.ultimo_acesso && (
|
||
<p className="text-[10px] text-gray-400">Último acesso: {fmt(u.ultimo_acesso)}</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Desktop: Table */}
|
||
<div className="hidden sm:block bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="bg-gray-50 border-b border-gray-100">
|
||
{['Usuário', 'Perfil', 'Status', 'Assinatura', 'Validade', 'Último acesso', 'Ações'].map(h => (
|
||
<th key={h} className="text-left px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-50">
|
||
{loading && <tr><td colSpan={7} className="text-center py-12"><Spinner /></td></tr>}
|
||
{!loading && data.length === 0 && (
|
||
<tr><td colSpan={7} className="text-center py-12 text-gray-400 text-sm font-medium">Nenhum usuário nesta clínica</td></tr>
|
||
)}
|
||
{data.map(u => (
|
||
<tr key={u.id} className={`hover:bg-gray-50/50 transition-colors ${u.ativo === false ? 'opacity-60' : ''}`}>
|
||
<td className="px-4 py-3">
|
||
<p className="font-bold text-gray-900 text-sm">{u.nome}</p>
|
||
<p className="text-xs text-gray-400 font-medium">{u.email}</p>
|
||
</td>
|
||
<td className="px-4 py-3"><RoleBadge role={u.role} /></td>
|
||
<td className="px-4 py-3"><StatusBadge ok={u.ativo !== false} /></td>
|
||
<td className="px-4 py-3"><ASSBadge status={u.assinatura_status} validade={u.assinatura_validade} /></td>
|
||
<td className="px-4 py-3 text-xs text-gray-500 font-medium whitespace-nowrap">{fmtDate(u.assinatura_validade)}</td>
|
||
<td className="px-4 py-3 text-xs text-gray-400 font-medium whitespace-nowrap">{fmt(u.ultimo_acesso)}</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-1.5">
|
||
<button onClick={() => toggleStatus(u)} disabled={togglingId === u.id}
|
||
className={`flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-black uppercase transition-all ${
|
||
u.ativo !== false ? 'bg-red-50 text-red-600 hover:bg-red-100 border border-red-200' : 'bg-green-50 text-green-600 hover:bg-green-100 border border-green-200'
|
||
}`}>
|
||
{togglingId === u.id ? <RefreshCw size={10} className="animate-spin" /> : u.ativo !== false ? <><ToggleLeft size={10} />Suspen.</> : <><ToggleRight size={10} />Ativar</>}
|
||
</button>
|
||
<button onClick={() => setAssinaturaModal(u)}
|
||
className="flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-black uppercase bg-blue-50 text-blue-600 hover:bg-blue-100 border border-blue-200 transition-all">
|
||
<DollarSign size={10} />Pag.
|
||
</button>
|
||
<button onClick={() => deleteUser(u)}
|
||
className="flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-black uppercase bg-gray-50 text-gray-500 hover:bg-red-50 hover:text-red-600 border border-gray-200 hover:border-red-200 transition-all">
|
||
<Trash2 size={10} />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 bg-gray-50">
|
||
<span className="text-xs text-gray-400 font-medium">{total} usuários · pág. {page}/{totalPages}</span>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}
|
||
className="p-1.5 rounded-lg border border-gray-200 bg-white disabled:opacity-40 hover:bg-gray-50 transition-all">
|
||
<ChevronLeft size={14} className="text-gray-600" />
|
||
</button>
|
||
<button onClick={() => setPage(p => p + 1)} disabled={page >= totalPages}
|
||
className="p-1.5 rounded-lg border border-gray-200 bg-white disabled:opacity-40 hover:bg-gray-50 transition-all">
|
||
<ChevronRight size={14} className="text-gray-600" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Mobile pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="sm:hidden flex items-center justify-between px-1">
|
||
<span className="text-xs text-gray-400 font-medium">{total} usuários · pág. {page}/{totalPages}</span>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}
|
||
className="p-2 rounded-xl border border-gray-200 bg-white disabled:opacity-40">
|
||
<ChevronLeft size={14} className="text-gray-600" />
|
||
</button>
|
||
<button onClick={() => setPage(p => p + 1)} disabled={page >= totalPages}
|
||
className="p-2 rounded-xl border border-gray-200 bg-white disabled:opacity-40">
|
||
<ChevronRight size={14} className="text-gray-600" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{assinaturaModal && <AssinaturaModal user={assinaturaModal} onClose={() => { setAssinaturaModal(null); load(); }} />}
|
||
{menuOpen && <div className="fixed inset-0 z-10" onClick={() => setMenuOpen(null)} />}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/* ── Assinatura Modal ── */
|
||
const AssinaturaModal: React.FC<{ user: any; onClose: () => void }> = ({ user, onClose }) => {
|
||
const [status, setStatus] = useState('pago');
|
||
const [meses, setMeses] = useState(1);
|
||
const [valor, setValor] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
const save = async () => {
|
||
setSaving(true);
|
||
await apiFetch(`/api/superadmin/assinaturas/${user.id}`, { method: 'PUT', body: JSON.stringify({ status, meses, valor: parseFloat(valor) || 0 }) });
|
||
setSaving(false);
|
||
onClose();
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-end sm:items-center justify-center z-50 p-0 sm:p-4"
|
||
onClick={onClose}>
|
||
<div className="bg-white rounded-t-3xl sm:rounded-3xl border border-gray-100 shadow-2xl w-full sm:max-w-sm p-6 pb-8 sm:pb-6"
|
||
onClick={e => e.stopPropagation()}>
|
||
<div className="flex items-center justify-between mb-1">
|
||
<h3 className="text-base font-black text-gray-900 uppercase tracking-tight">Registrar Pagamento</h3>
|
||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 transition-all">
|
||
<X size={16} className="text-gray-400" />
|
||
</button>
|
||
</div>
|
||
<p className="text-sm text-gray-500 mb-5 truncate">{user.nome} · <span className="font-bold">{user.email}</span></p>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5 block">Status</label>
|
||
<select value={status} onChange={e => setStatus(e.target.value)}
|
||
className="w-full px-3 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm font-bold outline-none focus:border-blue-400 transition-all">
|
||
<option value="pago">Pago</option>
|
||
<option value="pendente">Pendente</option>
|
||
<option value="cancelado">Cancelado</option>
|
||
</select>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5 block">Meses</label>
|
||
<input type="number" min={1} max={12} value={meses} onChange={e => setMeses(parseInt(e.target.value))}
|
||
className="w-full px-3 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm font-bold outline-none focus:border-blue-400 transition-all" />
|
||
</div>
|
||
<div>
|
||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5 block">Valor (R$)</label>
|
||
<input type="number" step="0.01" value={valor} onChange={e => setValor(e.target.value)} placeholder="0,00"
|
||
className="w-full px-3 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm font-bold outline-none focus:border-blue-400 transition-all" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-3 mt-5">
|
||
<button onClick={onClose} className="flex-1 py-3 rounded-2xl bg-gray-100 text-gray-600 font-black uppercase tracking-widest text-sm hover:bg-gray-200 transition-all">Cancelar</button>
|
||
<button onClick={save} disabled={saving}
|
||
className="flex-[2] py-3 rounded-2xl bg-blue-600 text-white font-black uppercase tracking-widest text-sm shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all flex items-center justify-center gap-2 disabled:opacity-60">
|
||
{saving ? <><RefreshCw size={16} className="animate-spin" />Salvando...</> : <><Save size={16} />Confirmar</>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/* ═══════════════════════════════════════════════════ TAB CLINICAS */
|
||
const TabClinicas: React.FC = () => {
|
||
const confirm = useConfirm();
|
||
const [data, setData] = useState<any[]>([]);
|
||
const [search, setSearch] = useState('');
|
||
const [estado, setEstado] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const ESTADOS = ['AC','AL','AM','AP','BA','CE','DF','ES','GO','MA','MG','MS','MT','PA','PB','PE','PI','PR','RJ','RN','RO','RR','RS','SC','SE','SP','TO'];
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (search) params.set('search', search);
|
||
if (estado) params.set('estado', estado);
|
||
const res = await apiFetch(`/api/superadmin/clinicas?${params}`);
|
||
const d = await res.json();
|
||
setData(Array.isArray(d) ? d : []);
|
||
} finally { setLoading(false); }
|
||
}, [search, estado]);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const deleteClinica = async (c: any) => {
|
||
if (!await confirm(`EXCLUIR "${c.nome_fantasia}" PERMANENTEMENTE?`)) return;
|
||
await apiFetch(`/api/superadmin/clinicas/${c.id}`, { method: 'DELETE' });
|
||
load();
|
||
};
|
||
|
||
const grouped: Record<string, any[]> = {};
|
||
for (const c of data) {
|
||
const key = c.estado || 'Não informado';
|
||
(grouped[key] ??= []).push(c);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3 sm:space-y-4">
|
||
<div className="flex gap-2 sm:gap-3">
|
||
<div className="relative flex-1">
|
||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||
<input value={search} onChange={e => setSearch(e.target.value)}
|
||
className="w-full pl-9 pr-3 py-2.5 bg-white border border-gray-200 rounded-xl text-sm font-medium outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-50 transition-all"
|
||
placeholder="Buscar clínica..." />
|
||
</div>
|
||
<select value={estado} onChange={e => setEstado(e.target.value)}
|
||
className="px-2 sm:px-3 py-2.5 bg-white border border-gray-200 rounded-xl text-[10px] sm:text-xs font-black uppercase outline-none focus:border-blue-400 transition-all max-w-[80px] sm:max-w-none">
|
||
<option value="">UF</option>
|
||
{ESTADOS.map(e => <option key={e} value={e}>{e}</option>)}
|
||
</select>
|
||
<button onClick={load} className="p-2.5 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition-all shrink-0">
|
||
<RefreshCw size={14} className={`text-gray-400 ${loading ? 'animate-spin' : ''}`} />
|
||
</button>
|
||
</div>
|
||
|
||
{loading ? <Spinner /> : Object.keys(grouped).sort().map(est => (
|
||
<div key={est}>
|
||
<div className="flex items-center gap-2 mb-2 px-1">
|
||
<MapPin size={11} className="text-gray-400" />
|
||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{est}</span>
|
||
<span className="text-[9px] font-black bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">{grouped[est].length}</span>
|
||
</div>
|
||
|
||
{/* Mobile: cards */}
|
||
<div className="sm:hidden space-y-2 mb-4">
|
||
{grouped[est].map(c => (
|
||
<div key={c.id} className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0 flex-1">
|
||
<p className="font-black text-gray-900 text-sm truncate">{c.nome_fantasia}</p>
|
||
<p className="text-[10px] text-gray-400">{c.documento || '—'}</p>
|
||
</div>
|
||
<button onClick={() => deleteClinica(c)}
|
||
className="shrink-0 p-1.5 rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-600 transition-all">
|
||
<Trash2 size={15} />
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center gap-3 mt-2.5 text-xs text-gray-500">
|
||
{c.dono_nome && <span className="font-medium truncate max-w-[140px]">{c.dono_nome}</span>}
|
||
{c.cidade && <span className="flex items-center gap-1 shrink-0"><MapPin size={10} />{c.cidade}</span>}
|
||
<span className="shrink-0 font-bold text-gray-400">{c.membros} membros</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Desktop: table */}
|
||
<div className="hidden sm:block bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden mb-4">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="bg-gray-50 border-b border-gray-100">
|
||
{['Clínica', 'Dono', 'Membros', 'Cidade', 'Criada em', ''].map(h => (
|
||
<th key={h} className="text-left px-4 py-2.5 text-[10px] font-black text-gray-400 uppercase tracking-widest">{h}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-50">
|
||
{grouped[est].map(c => (
|
||
<tr key={c.id} className="hover:bg-gray-50/50 transition-colors">
|
||
<td className="px-4 py-3">
|
||
<p className="font-bold text-gray-900">{c.nome_fantasia}</p>
|
||
<p className="text-xs text-gray-400">{c.documento || '—'}</p>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<p className="text-sm font-medium text-gray-700">{c.dono_nome || '—'}</p>
|
||
<p className="text-xs text-gray-400">{c.dono_email || ''}</p>
|
||
</td>
|
||
<td className="px-4 py-3 text-sm font-bold text-gray-700 text-center">{c.membros}</td>
|
||
<td className="px-4 py-3 text-sm text-gray-500">{c.cidade || '—'}</td>
|
||
<td className="px-4 py-3 text-xs text-gray-400 whitespace-nowrap">{fmtDate(c.createdat)}</td>
|
||
<td className="px-4 py-3">
|
||
<button onClick={() => deleteClinica(c)}
|
||
className="flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-black uppercase bg-gray-50 text-gray-500 hover:bg-red-50 hover:text-red-600 border border-gray-200 hover:border-red-200 transition-all">
|
||
<Trash2 size={12} />Excluir
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/* ═══════════════════════════════════════════════════ TAB LOGS */
|
||
const TabLogs: React.FC = () => {
|
||
const [data, setData] = useState<any[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [page, setPage] = useState(1);
|
||
const [loading, setLoading] = useState(false);
|
||
const PAGE_SIZE = 50;
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await apiFetch(`/api/superadmin/login-log?page=${page}&pageSize=${PAGE_SIZE}`);
|
||
const json = await res.json();
|
||
setData(json.data || []); setTotal(json.total || 0);
|
||
} finally { setLoading(false); }
|
||
}, [page]);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||
const statusCfg: Record<string, { bg: string; icon: React.ElementType; label: string; dot: string }> = {
|
||
ok: { bg: 'text-green-600 bg-green-50 border-green-200', icon: Wifi, label: 'Sucesso', dot: 'bg-green-500' },
|
||
falha: { bg: 'text-red-600 bg-red-50 border-red-200', icon: WifiOff, label: 'Falha', dot: 'bg-red-500' },
|
||
suspenso: { bg: 'text-orange-600 bg-orange-50 border-orange-200', icon: XCircle, label: 'Suspenso', dot: 'bg-orange-500' },
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-3 sm:space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<SectionTitle>Log de Acessos — {total} registros</SectionTitle>
|
||
<button onClick={load} className="p-2 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition-all">
|
||
<RefreshCw size={14} className={`text-gray-400 ${loading ? 'animate-spin' : ''}`} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Mobile: timeline cards */}
|
||
<div className="sm:hidden space-y-2">
|
||
{loading && <Spinner />}
|
||
{data.map(l => {
|
||
const cfg = statusCfg[l.status] || statusCfg.falha;
|
||
return (
|
||
<div key={l.id} className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3.5">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||
<span className={`w-2 h-2 rounded-full shrink-0 ${cfg.dot}`} />
|
||
<div className="min-w-0">
|
||
<p className="font-bold text-gray-900 text-sm truncate">{l.nome || l.email}</p>
|
||
{l.nome && <p className="text-[10px] text-gray-400 truncate">{l.email}</p>}
|
||
</div>
|
||
</div>
|
||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-black uppercase border shrink-0 ${cfg.bg}`}>
|
||
{cfg.label}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-3 mt-2 text-[10px] text-gray-400">
|
||
<span>{fmt(l.created_at)}</span>
|
||
{l.role && <RoleBadge role={l.role} />}
|
||
<span className="font-mono">{l.ip}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Desktop: table */}
|
||
<div className="hidden sm:block bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="bg-gray-50 border-b border-gray-100">
|
||
{['Data / Hora', 'Usuário', 'Perfil', 'IP', 'Status'].map(h => (
|
||
<th key={h} className="text-left px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-50">
|
||
{loading && <tr><td colSpan={5} className="text-center py-12"><Spinner /></td></tr>}
|
||
{data.map(l => {
|
||
const cfg = statusCfg[l.status] || statusCfg.falha;
|
||
const Icon = cfg.icon;
|
||
return (
|
||
<tr key={l.id} className="hover:bg-gray-50/50 transition-colors">
|
||
<td className="px-4 py-3 text-xs text-gray-500 font-medium whitespace-nowrap">{fmt(l.created_at)}</td>
|
||
<td className="px-4 py-3">
|
||
<p className="font-bold text-gray-900">{l.nome || '—'}</p>
|
||
<p className="text-xs text-gray-400">{l.email}</p>
|
||
</td>
|
||
<td className="px-4 py-3">{l.role ? <RoleBadge role={l.role} /> : <span className="text-xs text-gray-300">—</span>}</td>
|
||
<td className="px-4 py-3 text-xs font-mono text-gray-400">{l.ip}</td>
|
||
<td className="px-4 py-3">
|
||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-black uppercase border ${cfg.bg}`}>
|
||
<Icon size={10} />{cfg.label}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 bg-gray-50">
|
||
<span className="text-xs text-gray-400 font-medium">pág. {page}/{totalPages}</span>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}
|
||
className="p-1.5 rounded-lg border border-gray-200 bg-white disabled:opacity-40 hover:bg-gray-50 transition-all">
|
||
<ChevronLeft size={14} className="text-gray-600" />
|
||
</button>
|
||
<button onClick={() => setPage(p => p + 1)} disabled={page >= totalPages}
|
||
className="p-1.5 rounded-lg border border-gray-200 bg-white disabled:opacity-40 hover:bg-gray-50 transition-all">
|
||
<ChevronRight size={14} className="text-gray-600" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Mobile pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="sm:hidden flex items-center justify-between px-1">
|
||
<span className="text-xs text-gray-400 font-medium">pág. {page}/{totalPages}</span>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}
|
||
className="p-2 rounded-xl border border-gray-200 bg-white disabled:opacity-40">
|
||
<ChevronLeft size={14} className="text-gray-600" />
|
||
</button>
|
||
<button onClick={() => setPage(p => p + 1)} disabled={page >= totalPages}
|
||
className="p-2 rounded-xl border border-gray-200 bg-white disabled:opacity-40">
|
||
<ChevronRight size={14} className="text-gray-600" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/* ═══════════════════════════════════════════════════ TAB FINANCEIRO */
|
||
const TabFinanceiro: React.FC = () => {
|
||
const [pricing, setPricing] = useState<Record<string, number>>({ donoconsultorio: 49.90, donoclinica: 499.00, dentista: 49.90, biomedico: 49.90, protetico: 49.90, paciente: 0, 'plugin:locacao-salas': 0, 'plugin:profissionais-marketplace': 0 });
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [saved, setSaved] = useState(false);
|
||
|
||
useEffect(() => {
|
||
apiFetch('/api/superadmin/pricing').then(r => r.json()).then(d => { setPricing(d); setLoading(false); });
|
||
}, []);
|
||
|
||
const save = async () => {
|
||
setSaving(true);
|
||
await apiFetch('/api/superadmin/pricing', { method: 'PUT', body: JSON.stringify(pricing) });
|
||
setSaving(false); setSaved(true);
|
||
setTimeout(() => setSaved(false), 2500);
|
||
};
|
||
|
||
const pricingRows = [
|
||
{ role: 'donoconsultorio', label: 'Consultório', sub: '1 cadeira / solo', icon: Briefcase, color: 'emerald' },
|
||
{ role: 'donoclinica', label: 'Clínica', sub: 'multi-cadeira', icon: Building2, color: 'blue' },
|
||
{ role: 'dentista', label: 'Dentista', sub: 'por usuário/mês', icon: Stethoscope, color: 'indigo' },
|
||
{ role: 'biomedico', label: 'Biomédico(a)',sub: 'por usuário/mês', icon: Sparkles, color: 'pink' },
|
||
{ role: 'protetico', label: 'Protético', sub: 'por usuário/mês', icon: FlaskConical, color: 'violet' },
|
||
{ role: 'paciente', label: 'Paciente', sub: '0 = gratuito', icon: User, color: 'gray' },
|
||
];
|
||
|
||
// Add-ons: mesma tabela (settings.pricing), chave `plugin:<id do plugin>`.
|
||
const pluginRows = [
|
||
{ role: 'plugin:locacao-salas', label: 'Locação de Salas', sub: 'add-on por conta/mês', icon: DoorOpen, color: 'teal' },
|
||
{ role: 'plugin:profissionais-marketplace', label: 'Profissionais', sub: 'add-on por conta/mês', icon: UserSearch, color: 'orange' },
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-5 sm:space-y-8 max-w-2xl">
|
||
<div>
|
||
<SectionTitle>Tabela de Preços</SectionTitle>
|
||
<p className="text-sm text-gray-500 mt-1">Configure o valor mensal por perfil.</p>
|
||
</div>
|
||
|
||
{loading ? <Spinner /> : (
|
||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||
{(() => {
|
||
const colors: Record<string, string> = {
|
||
emerald: 'bg-emerald-50 text-emerald-600',
|
||
blue: 'bg-blue-50 text-blue-600',
|
||
indigo: 'bg-indigo-50 text-indigo-600',
|
||
pink: 'bg-pink-50 text-pink-600',
|
||
violet: 'bg-violet-50 text-violet-600',
|
||
gray: 'bg-gray-100 text-gray-500',
|
||
teal: 'bg-teal-50 text-teal-600',
|
||
orange: 'bg-orange-50 text-orange-600',
|
||
};
|
||
const renderRow = ({ role, label, sub, icon: Icon, color }: typeof pricingRows[number]) => (
|
||
<div key={role} className="flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3.5 sm:py-4">
|
||
<div className={`w-9 h-9 sm:w-10 sm:h-10 rounded-xl flex items-center justify-center shrink-0 ${colors[color]}`}>
|
||
<Icon size={16} />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-black text-gray-900 uppercase tracking-tight">{label}</p>
|
||
<p className="text-[10px] text-gray-400 font-medium hidden sm:block">{sub}</p>
|
||
</div>
|
||
<div className="flex items-center gap-1 sm:gap-1.5 shrink-0">
|
||
<span className="text-sm font-black text-gray-500">R$</span>
|
||
<input
|
||
type="number" step="0.01" min="0"
|
||
value={pricing[role] ?? 0}
|
||
onChange={e => setPricing(p => ({ ...p, [role]: parseFloat(e.target.value) || 0 }))}
|
||
className="w-20 sm:w-24 px-2 sm:px-3 py-2 bg-gray-50 border border-gray-200 rounded-xl text-sm font-black text-right outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-50 transition-all"
|
||
/>
|
||
<span className="text-[10px] text-gray-400 font-medium hidden sm:block">/mês</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
return (
|
||
<>
|
||
<div className="divide-y divide-gray-50">{pricingRows.map(renderRow)}</div>
|
||
<div className="px-4 sm:px-5 pt-4 pb-1 bg-gray-50/60 border-t border-gray-100">
|
||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Plugins (add-ons) · 0 = incluído no plano</p>
|
||
</div>
|
||
<div className="divide-y divide-gray-50">{pluginRows.map(renderRow)}</div>
|
||
</>
|
||
);
|
||
})()}
|
||
|
||
<div className="px-4 sm:px-5 py-4 bg-gray-50 border-t border-gray-100 flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:justify-between">
|
||
<p className="text-[10px] sm:text-xs text-gray-400 font-medium">Alterações entram em vigor no próximo ciclo.</p>
|
||
<button onClick={save} disabled={saving}
|
||
className={`w-full sm:w-auto flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl text-xs font-black uppercase tracking-widest shadow-sm transition-all ${
|
||
saved ? 'bg-emerald-500 text-white' : 'bg-blue-600 text-white hover:bg-blue-700 shadow-blue-200'
|
||
} disabled:opacity-60`}>
|
||
{saving ? <><RefreshCw size={14} className="animate-spin" />Salvando...</> : saved ? <><CheckCircle2 size={14} />Salvo!</> : <><Save size={14} />Salvar Tabela</>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="bg-blue-50 border border-blue-100 rounded-2xl p-4 sm:p-5">
|
||
<p className="text-[10px] sm:text-xs font-black text-blue-600 uppercase tracking-widest mb-2">Como funciona</p>
|
||
<ul className="space-y-1.5 text-xs sm:text-sm text-blue-800 font-medium">
|
||
<li>• Registre o pagamento na aba <strong>Usuários</strong> → botão <strong>Pag.</strong></li>
|
||
<li>• Defina quantos meses o acesso ficará liberado (1–12)</li>
|
||
<li>• Ao suspender, o usuário não consegue mais fazer login</li>
|
||
<li>• Usuários vencidos aparecem como <strong>Pendente</strong></li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/* ═══════════════════════════════════════════════════ TAB NOTIFICACOES */
|
||
const TabNotificacoes: React.FC = () => {
|
||
const [titulo, setTitulo] = useState('');
|
||
const [mensagem, setMensagem] = useState('');
|
||
const [tipo, setTipo] = useState<'info' | 'sucesso' | 'alerta' | 'erro'>('info');
|
||
const [targetEmail, setTargetEmail] = useState('');
|
||
const [sending, setSending] = useState(false);
|
||
const [sent, setSent] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [history, setHistory] = useState<any[]>([]);
|
||
const [loadingH, setLoadingH] = useState(true);
|
||
|
||
const loadHistory = async () => {
|
||
setLoadingH(true);
|
||
try {
|
||
const res = await apiFetch('/api/superadmin/notificacoes-history');
|
||
if (res.ok) setHistory(await res.json());
|
||
} finally { setLoadingH(false); }
|
||
};
|
||
|
||
useEffect(() => { loadHistory(); }, []);
|
||
|
||
const resolveTarget = async (): Promise<string | null> => {
|
||
if (!targetEmail.trim()) return null;
|
||
const res = await apiFetch(`/api/superadmin/usuarios?search=${encodeURIComponent(targetEmail.trim())}&pageSize=1`);
|
||
const json = await res.json();
|
||
const user = json.data?.[0];
|
||
if (!user) { setError(`Usuário "${targetEmail}" não encontrado.`); return 'NOT_FOUND'; }
|
||
return user.id;
|
||
};
|
||
|
||
const handleSend = async () => {
|
||
if (!titulo.trim() || !mensagem.trim()) { setError('Preencha título e mensagem.'); return; }
|
||
setSending(true); setError('');
|
||
try {
|
||
let usuario_id: string | null | undefined = undefined;
|
||
if (targetEmail.trim()) {
|
||
const resolved = await resolveTarget();
|
||
if (resolved === 'NOT_FOUND') { setSending(false); return; }
|
||
usuario_id = resolved;
|
||
}
|
||
const res = await apiFetch('/api/superadmin/notificacoes', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ titulo: titulo.trim(), mensagem: mensagem.trim(), tipo, usuario_id }),
|
||
});
|
||
if (res.ok) {
|
||
setSent(true); setTitulo(''); setMensagem(''); setTargetEmail('');
|
||
setTimeout(() => setSent(false), 3000);
|
||
loadHistory();
|
||
} else {
|
||
const d = await res.json();
|
||
setError(d.error || 'Erro ao enviar.');
|
||
}
|
||
} catch { setError('Falha na conexão.'); }
|
||
finally { setSending(false); }
|
||
};
|
||
|
||
const TIPO_COLORS: Record<string, string> = {
|
||
info: 'bg-blue-50 text-blue-700 border-blue-200',
|
||
sucesso: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||
alerta: 'bg-yellow-50 text-yellow-700 border-yellow-200',
|
||
erro: 'bg-red-50 text-red-700 border-red-200',
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-5 sm:space-y-6 max-w-2xl">
|
||
<div>
|
||
<SectionTitle>Enviar Notificação</SectionTitle>
|
||
<p className="text-sm text-gray-500 mt-1">Envie avisos globais ou para um usuário específico.</p>
|
||
</div>
|
||
|
||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-6 space-y-4">
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5">Destino (opcional)</label>
|
||
<div className="relative">
|
||
<Phone size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||
<input value={targetEmail} onChange={e => setTargetEmail(e.target.value)}
|
||
placeholder="E-mail do usuário — vazio = todos"
|
||
className="w-full pl-9 border border-gray-200 rounded-xl px-4 py-2.5 text-sm font-medium text-gray-800 focus:ring-2 focus:ring-blue-100 focus:border-blue-300 outline-none transition-all" />
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5">Tipo</label>
|
||
<div className="grid grid-cols-4 gap-2">
|
||
{(['info', 'sucesso', 'alerta', 'erro'] as const).map(t => (
|
||
<button key={t} onClick={() => setTipo(t)}
|
||
className={`py-2 rounded-xl text-[10px] font-black uppercase border transition-all ${tipo === t ? TIPO_COLORS[t] : 'bg-gray-50 text-gray-400 border-gray-200 hover:bg-gray-100'}`}>
|
||
{t}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5">Título</label>
|
||
<input value={titulo} onChange={e => setTitulo(e.target.value)}
|
||
placeholder="Ex: Manutenção programada"
|
||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm font-medium text-gray-800 focus:ring-2 focus:ring-blue-100 focus:border-blue-300 outline-none transition-all" />
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5">Mensagem</label>
|
||
<textarea value={mensagem} onChange={e => setMensagem(e.target.value)} rows={3}
|
||
placeholder="Texto completo da notificação..."
|
||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm font-medium text-gray-800 focus:ring-2 focus:ring-blue-100 focus:border-blue-300 outline-none transition-all resize-none" />
|
||
</div>
|
||
|
||
{error && <p className="text-xs text-red-600 font-bold bg-red-50 px-4 py-2.5 rounded-xl border border-red-100">{error}</p>}
|
||
|
||
<button onClick={handleSend} disabled={sending}
|
||
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-2xl text-xs font-black uppercase tracking-widest bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-all shadow-lg shadow-blue-200">
|
||
{sending ? <><RefreshCw size={14} className="animate-spin" />Enviando...</>
|
||
: sent ? <><CheckCircle2 size={14} />Enviado com sucesso!</>
|
||
: <><Send size={14} />Enviar Notificação</>}
|
||
</button>
|
||
</div>
|
||
|
||
<div>
|
||
<SectionTitle>Últimas Notificações Enviadas</SectionTitle>
|
||
<div className="mt-3 space-y-2">
|
||
{loadingH ? <Spinner /> : history.length === 0 ? (
|
||
<p className="text-sm text-gray-400 text-center py-8">Nenhuma notificação enviada ainda.</p>
|
||
) : history.map((n: any) => (
|
||
<div key={n.id} className={`flex items-start gap-3 px-4 py-3 rounded-2xl border text-sm ${TIPO_COLORS[n.tipo] || TIPO_COLORS.info}`}>
|
||
<Bell size={13} className="shrink-0 mt-0.5" />
|
||
<div className="flex-1 min-w-0">
|
||
<p className="font-black text-xs uppercase truncate">{n.titulo}</p>
|
||
<p className="font-medium text-xs mt-0.5 opacity-80 line-clamp-2">{n.mensagem}</p>
|
||
<p className="text-[10px] opacity-60 mt-1">{fmt(n.data)} · {n.usuario_id ? `→ usuário específico` : '→ Todos'}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/* ─── Helpers ─── */
|
||
const Spinner: React.FC = () => (
|
||
<div className="flex justify-center py-10"><RefreshCw size={22} className="animate-spin text-blue-400" /></div>
|
||
);
|
||
const ErrorMsg: React.FC = () => (
|
||
<div className="flex justify-center py-10 text-gray-400 text-sm font-medium">Erro ao carregar. Tente novamente.</div>
|
||
);
|
||
const SectionTitle: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||
<h2 className="text-[10px] sm:text-xs font-black text-gray-400 uppercase tracking-widest">{children}</h2>
|
||
);
|