Files
VPS 4 Builder b67fa15c55 feat(superadmin): página "Secretária IA" com motivos de liga/desliga por sessão
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>
2026-07-06 20:02:39 +02:00

1123 lines
70 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (112)</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>
);