microtecflix/src/components/AdminDashboard.tsx

614 lines
29 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
TrendingUp, Users, AlertTriangle, XCircle, Search,
Settings, CheckCircle2, RefreshCw, Link as LinkIcon, ShieldCheck, HelpCircle, Eye
} from 'lucide-react';
import { SubscriptionStatus } from '../types';
interface WebhookLog {
id: string;
event: string;
payload: string;
receivedAt: string;
}
interface Student {
id: string;
name: string;
email: string;
subscriptionStatus: SubscriptionStatus;
asaasCustomerId: string | null;
createdAt: string;
totalPaid: number;
unlockedCourses?: string[];
}
interface PaymentLog {
id: string;
userId: string;
studentName: string;
studentEmail: string;
value: number;
status: 'PENDING' | 'RECEIVED' | 'CONFIRMED' | 'OVERDUE' | 'REFUNDED';
billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO';
invoiceUrl: string;
dueDate: string;
paidAt: string | null;
createdAt: string;
}
interface DashboardStats {
mrr: number;
activeSubscribers: number;
trialSubscribers: number;
overdueSubscribers: number;
canceledSubscribers: number;
}
interface AdminDashboardProps {
token: string | null;
}
export default function AdminDashboard({ token }: AdminDashboardProps) {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [payments, setPayments] = useState<PaymentLog[]>([]);
const [webhookLogs, setWebhookLogs] = useState<WebhookLog[]>([]);
const [students, setStudents] = useState<Student[]>([]);
const [courses, setCourses] = useState<any[]>([]);
const [expandedStudentId, setExpandedStudentId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState<'finance' | 'students' | 'webhooks'>('finance');
const [isLoading, setIsLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
useEffect(() => {
fetchDashboardData();
fetchStudents();
fetchCourses();
}, []);
const fetchCourses = async () => {
try {
const res = await fetch('/api/courses', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setCourses(data);
}
} catch (err) {
console.error('Error fetching courses:', err);
}
};
const fetchDashboardData = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/admin/dashboard', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setStats(data.stats);
setPayments(data.recentPayments);
setWebhookLogs(data.webhookLogs);
}
} catch (err) {
console.error('Error fetching dashboard stats:', err);
} finally {
setIsLoading(false);
}
};
const fetchStudents = async () => {
try {
const res = await fetch('/api/admin/students', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setStudents(data);
}
} catch (err) {
console.error('Error fetching students:', err);
}
};
const handleChangeStudentStatus = async (studentId: string, newStatus: SubscriptionStatus) => {
setActionLoading(studentId);
try {
const res = await fetch(`/api/admin/students/${studentId}/status`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ status: newStatus })
});
if (res.ok) {
// Refresh local student details
await fetchStudents();
await fetchDashboardData();
}
} catch (err) {
console.error('Error changing student status:', err);
} finally {
setActionLoading(null);
}
};
const handleToggleCourseUnlock = async (studentId: string, courseId: string, currentUnlocked: boolean) => {
setActionLoading(`${studentId}_${courseId}`);
try {
const res = await fetch(`/api/admin/students/${studentId}/unlock-course`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ courseId, unlocked: !currentUnlocked })
});
if (res.ok) {
await fetchStudents();
}
} catch (err) {
console.error('Error toggling course unlock:', err);
} finally {
setActionLoading(null);
}
};
const getStatusTag = (status: SubscriptionStatus) => {
switch (status) {
case 'ACTIVE':
return <span className="bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 px-2.5 py-0.5 rounded-full text-xs font-bold tracking-wide">ATIVO</span>;
case 'TRIAL':
return <span className="bg-amber-500/10 text-amber-500 border border-amber-500/20 px-2.5 py-0.5 rounded-full text-xs font-bold tracking-wide">PERÍODO TESTE</span>;
case 'OVERDUE':
return <span className="bg-red-500/10 text-red-500 border border-red-500/20 px-2.5 py-0.5 rounded-full text-xs font-bold tracking-wide">INADIMPLENTE</span>;
case 'CANCELED':
return <span className="bg-zinc-800 text-zinc-400 border border-zinc-700 px-2.5 py-0.5 rounded-full text-xs font-bold tracking-wide">CANCELADO</span>;
default:
return <span className="bg-zinc-700 text-zinc-300 px-2.5 py-0.5 rounded-full text-xs font-bold tracking-wide">{status}</span>;
}
};
const getPaymentStatusTag = (status: string) => {
switch (status) {
case 'CONFIRMED':
case 'RECEIVED':
return <span className="text-emerald-500 font-bold"> Confirmado</span>;
case 'PENDING':
return <span className="text-amber-500 font-bold"> Pendente</span>;
case 'OVERDUE':
return <span className="text-red-500 font-bold"> Vencido</span>;
default:
return <span className="text-zinc-500">{status}</span>;
}
};
const filteredStudents = students.filter(student =>
student.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
student.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
(student.asaasCustomerId && student.asaasCustomerId.toLowerCase().includes(searchQuery.toLowerCase()))
);
if (isLoading || !stats) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] text-zinc-400 space-y-4">
<RefreshCw className="w-8 h-8 text-[#E50914] animate-spin" />
<p className="text-sm font-semibold">Atualizando métricas de faturamento...</p>
</div>
);
}
return (
<div className="space-y-8">
{/* Dashboard Top Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between border-b border-white/10 pb-5 gap-4">
<div>
<h1 className="text-2xl md:text-3xl font-display font-extrabold text-white tracking-tight flex items-center space-x-2">
<ShieldCheck className="w-8 h-8 text-[#E50914]" />
<span>Painel de Controle Financeiro & Alunos</span>
</h1>
<p className="text-xs text-zinc-500 mt-1">Gerencie métricas de faturamento, assinaturas recorrentes e controle acessos.</p>
</div>
<button
onClick={() => { fetchDashboardData(); fetchStudents(); }}
className="glass-btn-secondary text-white font-bold text-xs px-4 py-2.5 rounded-lg flex items-center justify-center space-x-2 cursor-pointer self-start sm:self-auto"
>
<RefreshCw className="w-4 h-4" />
<span>Atualizar Dados</span>
</button>
</div>
{/* METRICS ROW (MRR, Actives, Overdue, Cancelled) */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{/* MRR Card */}
<div className="glass-card p-6 rounded-2xl flex items-center justify-between">
<div className="space-y-1">
<p className="text-xs text-zinc-500 font-bold uppercase tracking-wider">MRR (Recorrente Mensal)</p>
<p className="text-2xl md:text-3xl font-display font-black text-[#E50914] tracking-tight">
R$ {stats.mrr.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
</p>
<p className="text-[10px] text-zinc-400 font-medium">Faturamento estimado ativo</p>
</div>
<div className="bg-[#E50914]/10 text-[#E50914] p-3 rounded-xl border border-[#E50914]/20">
<TrendingUp className="w-6 h-6" />
</div>
</div>
{/* Active Subscribers Card */}
<div className="glass-card p-6 rounded-2xl flex items-center justify-between">
<div className="space-y-1">
<p className="text-xs text-zinc-500 font-bold uppercase tracking-wider">Assinantes Ativos</p>
<p className="text-2xl md:text-3xl font-display font-black text-white tracking-tight">
{stats.activeSubscribers}
</p>
<p className="text-[10px] text-zinc-400 font-medium">({stats.trialSubscribers} em período de teste)</p>
</div>
<div className="bg-emerald-500/10 text-emerald-500 p-3 rounded-xl border border-emerald-500/20">
<Users className="w-6 h-6" />
</div>
</div>
{/* Overdue Subscribers Card */}
<div className="glass-card p-6 rounded-2xl flex items-center justify-between">
<div className="space-y-1">
<p className="text-xs text-zinc-500 font-bold uppercase tracking-wider">Inadimplentes</p>
<p className="text-2xl md:text-3xl font-display font-black text-red-500 tracking-tight">
{stats.overdueSubscribers}
</p>
<p className="text-[10px] text-zinc-400 font-medium">Bloqueados ou com cobrança pendente</p>
</div>
<div className="bg-red-500/10 text-red-500 p-3 rounded-xl border border-red-500/20">
<AlertTriangle className="w-6 h-6" />
</div>
</div>
{/* Cancelled Card */}
<div className="glass-card p-6 rounded-2xl flex items-center justify-between">
<div className="space-y-1">
<p className="text-xs text-zinc-500 font-bold uppercase tracking-wider">Cancelamentos</p>
<p className="text-2xl md:text-3xl font-display font-black text-zinc-400 tracking-tight">
{stats.canceledSubscribers}
</p>
<p className="text-[10px] text-zinc-400 font-medium">Histórico total de contas canceladas</p>
</div>
<div className="bg-zinc-800 text-zinc-400 p-3 rounded-xl border border-zinc-700">
<XCircle className="w-6 h-6" />
</div>
</div>
</div>
{/* TABS CONTAINER */}
<div className="space-y-6">
<div className="flex border-b border-white/10">
<button
onClick={() => setActiveTab('finance')}
className={`px-5 py-3 font-semibold text-sm transition-colors border-b-2 cursor-pointer ${activeTab === 'finance' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
>
Faturamento Recorrente
</button>
<button
onClick={() => setActiveTab('students')}
className={`px-5 py-3 font-semibold text-sm transition-colors border-b-2 cursor-pointer ${activeTab === 'students' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
>
Gestão de Alunos ({students.length})
</button>
<button
onClick={() => setActiveTab('webhooks')}
className={`px-5 py-3 font-semibold text-sm transition-colors border-b-2 cursor-pointer ${activeTab === 'webhooks' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
>
Auditoria de Webhooks Asaas
</button>
</div>
{/* TAB 1: FINANCE / PAYMENTS */}
{activeTab === 'finance' && (
<div className="space-y-6">
<div className="glass-card rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-white/5 flex items-center justify-between">
<h3 className="font-display font-bold text-sm text-white">Transações e Cobranças Recorrentes</h3>
<span className="text-zinc-500 text-xs">Últimos lançamentos</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm divide-y divide-white/5">
<thead className="bg-white/3 text-xs text-zinc-400 font-bold uppercase">
<tr>
<th className="px-6 py-3.5">Aluno</th>
<th className="px-6 py-3.5">Valor</th>
<th className="px-6 py-3.5">Tipo</th>
<th className="px-6 py-3.5">Vencimento</th>
<th className="px-6 py-3.5">Status</th>
<th className="px-6 py-3.5">Comprovante Asaas</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5 bg-transparent">
{payments.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-zinc-500">Nenhum pagamento registrado ainda.</td>
</tr>
) : (
payments.map(pay => (
<tr key={pay.id} className="hover:bg-zinc-900/20 transition-colors">
<td className="px-6 py-4">
<div className="font-semibold text-white">{pay.studentName}</div>
<div className="text-xs text-zinc-500">{pay.studentEmail}</div>
</td>
<td className="px-6 py-4 font-bold text-zinc-200">
R$ {pay.value.toFixed(2)}
</td>
<td className="px-6 py-4">
<span className="text-xs font-mono bg-zinc-800 text-zinc-300 px-2 py-0.5 rounded-md border border-zinc-700">
{pay.billingType}
</span>
</td>
<td className="px-6 py-4 text-xs text-zinc-400">
{new Date(pay.dueDate).toLocaleDateString('pt-BR')}
</td>
<td className="px-6 py-4 text-xs">
{getPaymentStatusTag(pay.status)}
</td>
<td className="px-6 py-4">
<a
href={pay.invoiceUrl}
target="_blank"
rel="noreferrer"
className="text-xs text-zinc-400 hover:text-[#E50914] flex items-center space-x-1 transition-colors"
>
<LinkIcon className="w-3 h-3" />
<span>Segunda Via / Fatura</span>
</a>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* TAB 2: STUDENTS MANAGEMENT */}
{activeTab === 'students' && (
<div className="space-y-6">
{/* Search Bar */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3.5 top-3 w-4 h-4 text-zinc-500" />
<input
type="text"
placeholder="Pesquisar assinante por nome, e-mail ou código Asaas..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full glass-input rounded-lg py-2.5 pl-10 pr-4 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none transition-colors"
/>
</div>
</div>
<div className="glass-card rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm divide-y divide-white/5">
<thead className="bg-white/3 text-xs text-zinc-400 font-bold uppercase">
<tr>
<th className="px-6 py-3.5">Nome / E-mail</th>
<th className="px-6 py-3.5">ID Cliente Asaas</th>
<th className="px-6 py-3.5">Data Cadastro</th>
<th className="px-6 py-3.5">Total Pago</th>
<th className="px-6 py-3.5">Status Assinatura</th>
<th className="px-6 py-3.5 text-right">Ação Manual (Alterar Status)</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5 bg-transparent">
{filteredStudents.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-zinc-500">Nenhum assinante correspondente encontrado.</td>
</tr>
) : (
filteredStudents.map(student => (
<React.Fragment key={student.id}>
<tr className="hover:bg-zinc-900/20 transition-colors">
<td className="px-6 py-4">
<div className="font-semibold text-white">{student.name}</div>
<div className="text-xs text-zinc-500">{student.email}</div>
{student.unlockedCourses && student.unlockedCourses.length > 0 && (
<div className="mt-1 flex items-center gap-1">
<span className="bg-[#E50914]/25 text-[#E50914] text-[9px] font-extrabold px-1.5 py-0.5 rounded uppercase tracking-wider">
{student.unlockedCourses.length} {student.unlockedCourses.length === 1 ? 'Curso Avulso' : 'Cursos Avulsos'}
</span>
</div>
)}
</td>
<td className="px-6 py-4 text-xs font-mono text-zinc-400">
{student.asaasCustomerId || <span className="text-zinc-600 italic">Sem vínculo</span>}
</td>
<td className="px-6 py-4 text-xs text-zinc-400">
{new Date(student.createdAt).toLocaleDateString('pt-BR')}
</td>
<td className="px-6 py-4 font-bold text-zinc-200">
R$ {student.totalPaid.toFixed(2)}
</td>
<td className="px-6 py-4">
{getStatusTag(student.subscriptionStatus)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end space-x-1">
{/* Manage Individual Courses Access */}
<button
onClick={() => setExpandedStudentId(expandedStudentId === student.id ? null : student.id)}
className={`text-[10px] font-bold px-2 py-1 rounded transition-colors cursor-pointer ${expandedStudentId === student.id ? 'bg-[#E50914] text-white' : 'bg-zinc-850 text-zinc-300 hover:bg-zinc-700'}`}
title="Liberar Acesso Manual a Cursos Trancados"
>
{expandedStudentId === student.id ? 'Ocultar Cursos' : 'Gerenciar Cursos'}
</button>
{/* Activate */}
<button
onClick={() => handleChangeStudentStatus(student.id, 'ACTIVE')}
disabled={actionLoading === student.id}
className="bg-emerald-950/40 border border-emerald-900/50 hover:bg-emerald-900 text-emerald-400 text-[10px] font-bold px-2 py-1 rounded transition-colors"
title="Ativar Acesso"
>
Ativar
</button>
{/* Suspend / Overdue */}
<button
onClick={() => handleChangeStudentStatus(student.id, 'OVERDUE')}
disabled={actionLoading === student.id}
className="bg-amber-950/40 border border-amber-900/50 hover:bg-amber-900 text-amber-400 text-[10px] font-bold px-2 py-1 rounded transition-colors"
title="Suspender (Inadimplente)"
>
Suspender
</button>
{/* Grant Trial */}
<button
onClick={() => handleChangeStudentStatus(student.id, 'TRIAL')}
disabled={actionLoading === student.id}
className="bg-indigo-950/40 border border-indigo-900/50 hover:bg-indigo-900 text-indigo-400 text-[10px] font-bold px-2 py-1 rounded transition-colors"
title="Conceder Período de Testes"
>
Teste
</button>
{/* Cancel */}
<button
onClick={() => handleChangeStudentStatus(student.id, 'CANCELED')}
disabled={actionLoading === student.id}
className="bg-red-950/40 border border-red-900/50 hover:bg-red-900 text-red-400 text-[10px] font-bold px-2 py-1 rounded transition-colors"
title="Cancelar Conta"
>
Cancelar
</button>
</div>
</td>
</tr>
{expandedStudentId === student.id && (
<tr className="bg-black/30">
<td colSpan={6} className="px-6 py-4 border-b border-white/5">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold text-zinc-300 uppercase tracking-wider flex items-center gap-1.5">
<span>Liberar Acesso Manual a Cursos Trancados para {student.name}</span>
</h4>
<span className="text-[10px] text-zinc-500 italic">O preço de venda é definido no Gerenciador de Cursos</span>
</div>
{courses.filter(c => c.isLocked).length === 0 ? (
<p className="text-xs text-zinc-500 italic">Não cursos trancados cadastrados para desbloqueio individual.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
{courses.filter(c => c.isLocked).map(course => {
const isUnlockedForStudent = student.unlockedCourses?.includes(course.id) || false;
const isPendingAction = actionLoading === `${student.id}_${course.id}`;
return (
<div key={course.id} className="bg-zinc-900/80 border border-white/5 rounded-lg p-3 flex items-center justify-between text-xs">
<div className="min-w-0 pr-2">
<p className="font-bold text-white truncate">{course.title}</p>
<p className="text-[10px] text-[#E50914] font-semibold mt-0.5 font-mono">R$ {course.price?.toFixed(2) || '0.00'}</p>
</div>
<button
disabled={isPendingAction}
onClick={() => handleToggleCourseUnlock(student.id, course.id, isUnlockedForStudent)}
className={`px-2.5 py-1.5 rounded text-[10px] font-bold transition-all cursor-pointer ${
isUnlockedForStudent
? 'bg-emerald-950 text-emerald-400 border border-emerald-900 hover:bg-red-950 hover:text-red-400 hover:border-red-900'
: 'bg-zinc-800 text-zinc-400 hover:bg-emerald-950 hover:text-emerald-400 hover:border-emerald-900 border border-transparent'
}`}
>
{isPendingAction ? 'Aguarde...' : isUnlockedForStudent ? '✓ Liberado (Bloquear)' : 'Bloqueado (Liberar)'}
</button>
</div>
);
})}
</div>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* TAB 3: WEBHOOK AUDIT LOGS */}
{activeTab === 'webhooks' && (
<div className="space-y-6">
<div className="glass-card p-5 rounded-2xl space-y-4">
<div className="flex items-center space-x-2">
<HelpCircle className="w-5 h-5 text-zinc-400" />
<p className="text-xs text-zinc-400">
Os logs de eventos abaixo auditam as chamadas disparadas pela API do Asaas para o endpoint de integração do webhook <code className="bg-zinc-800 text-white px-1.5 py-0.5 rounded text-[10px]">/api/webhooks/asaas</code>.
</p>
</div>
</div>
<div className="glass-card rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm divide-y divide-white/5">
<thead className="bg-white/3 text-xs text-zinc-400 font-bold uppercase">
<tr>
<th className="px-6 py-3.5">ID Log</th>
<th className="px-6 py-3.5">Evento Asaas</th>
<th className="px-6 py-3.5">Data Processamento</th>
<th className="px-6 py-3.5">Payload Recebido</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5 bg-transparent">
{webhookLogs.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-zinc-500">Nenhum evento registrado no banco de dados.</td>
</tr>
) : (
webhookLogs.map(log => (
<tr key={log.id} className="hover:bg-zinc-900/10 transition-colors">
<td className="px-6 py-4 text-xs font-mono text-zinc-400">
{log.id}
</td>
<td className="px-6 py-4">
<span className={`text-xs font-bold px-2.5 py-1 rounded border ${
log.event.includes('RECEIVED') || log.event.includes('CONFIRMED')
? 'bg-emerald-950 text-emerald-400 border-emerald-900'
: log.event.includes('OVERDUE')
? 'bg-red-950 text-red-400 border-red-900'
: 'bg-zinc-800 text-zinc-300 border-zinc-700'
}`}>
{log.event}
</span>
</td>
<td className="px-6 py-4 text-xs text-zinc-400">
{new Date(log.receivedAt).toLocaleString('pt-BR')}
</td>
<td className="px-6 py-4">
<pre className="text-[10px] text-zinc-500 font-mono max-w-sm overflow-x-auto truncate bg-zinc-900 p-2 rounded-lg border border-zinc-850">
{log.payload}
</pre>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
</div>
);
}