feat: dashboard charts with recharts, mrr fix and dynamic payment product tracking
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 56s Details

This commit is contained in:
Sidney Gomes 2026-07-22 20:07:51 -03:00
parent 5e1218148b
commit 5bbe00b2f3
4 changed files with 145 additions and 6 deletions

View File

@ -24,6 +24,7 @@
"motion": "^12.23.24", "motion": "^12.23.24",
"react": "^19.0.1", "react": "^19.0.1",
"react-dom": "^19.0.1", "react-dom": "^19.0.1",
"recharts": "^2.15.0",
"vite": "^6.2.3" "vite": "^6.2.3"
}, },
"devDependencies": { "devDependencies": {

View File

@ -514,7 +514,8 @@ app.post('/api/courses/:id/unlock', authenticateToken, checkoutRateLimiter, asyn
invoiceUrl: asaasPayment.invoiceUrl, invoiceUrl: asaasPayment.invoiceUrl,
dueDate: asaasPayment.dueDate || new Date().toISOString().split('T')[0], dueDate: asaasPayment.dueDate || new Date().toISOString().split('T')[0],
paidAt: null, paidAt: null,
createdAt: new Date().toISOString() createdAt: new Date().toISOString(),
courseId: courseId
}; };
db.payments.push(newPayment); db.payments.push(newPayment);
saveDb(db); saveDb(db);
@ -726,7 +727,8 @@ app.post('/api/subscription/subscribe', authenticateToken, async (req: Authentic
invoiceUrl: invoiceUrlStr, invoiceUrl: invoiceUrlStr,
dueDate: asaasDueDate, dueDate: asaasDueDate,
paidAt: statusAsaas === 'CONFIRMED' ? new Date().toISOString() : null, paidAt: statusAsaas === 'CONFIRMED' ? new Date().toISOString() : null,
createdAt: new Date().toISOString() createdAt: new Date().toISOString(),
planId: planId
}; };
db.payments.push(newPayment); db.payments.push(newPayment);
@ -763,17 +765,87 @@ app.get('/api/admin/dashboard', authenticateToken, requireAdmin, (req: Authentic
const totalCanceled = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'CANCELED').length; const totalCanceled = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'CANCELED').length;
const totalTrial = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'TRIAL').length; const totalTrial = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'TRIAL').length;
// MRR = Active Subscribers * 49.90 // MRR Calculation
const mrr = totalActive * 49.90; let mrr = 0;
const activeUsers = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'ACTIVE');
for (const u of activeUsers) {
const studentPayments = db.payments
.filter(p => p.userId === u.id && (p.status === 'CONFIRMED' || p.status === 'RECEIVED'))
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
if (studentPayments.length > 0) {
const latest = studentPayments[0];
if (latest.planId) {
const plan = db.plans?.find(p => p.id === latest.planId);
if (plan) {
if (plan.cycle === 'MONTHLY') mrr += plan.price;
else if (plan.cycle === 'YEARLY') mrr += (plan.price / 12);
// LIFETIME does not add to MRR
} else {
mrr += latest.value; // Fallback
}
} else if (latest.courseId) {
// Avulso, 0 MRR
} else {
// Legacy payment, assume monthly
mrr += latest.value;
}
}
}
// Generate Revenue Data for Charts (Last 30 Days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const recentConfirmedPayments = db.payments.filter(
p => (p.status === 'CONFIRMED' || p.status === 'RECEIVED') && new Date(p.createdAt) >= thirtyDaysAgo
);
const revenueByDay: Record<string, number> = {};
const categoryRevenue: Record<string, number> = {};
for (const pay of recentConfirmedPayments) {
const dateStr = new Date(pay.createdAt).toISOString().split('T')[0];
revenueByDay[dateStr] = (revenueByDay[dateStr] || 0) + pay.value;
let category = 'Desconhecido';
if (pay.courseId) {
const c = db.courses.find(c => c.id === pay.courseId);
category = c ? `Curso: ${c.title}` : 'Avulso';
} else if (pay.planId) {
const p = db.plans?.find(p => p.id === pay.planId);
category = p ? `Plano: ${p.title}` : 'Assinatura';
} else {
category = 'Assinatura Legado';
}
categoryRevenue[category] = (categoryRevenue[category] || 0) + pay.value;
}
const revenueData = Object.keys(revenueByDay).sort().map(date => ({
date,
value: revenueByDay[date]
}));
const categoryData = Object.keys(categoryRevenue).map(name => ({
name,
value: categoryRevenue[name]
}));
// Let's format recent invoice history for logs/charts // Let's format recent invoice history for logs/charts
const recentPayments = db.payments const recentPayments = db.payments
.map(p => { .map(p => {
const student = db.users.find(u => u.id === p.userId); const student = db.users.find(u => u.id === p.userId);
let desc = '';
if (p.courseId) {
desc = `Curso: ${db.courses.find(c => c.id === p.courseId)?.title || p.courseId}`;
} else if (p.planId) {
desc = `Plano: ${db.plans?.find(pl => pl.id === p.planId)?.title || p.planId}`;
}
return { return {
...p, ...p,
studentName: student ? student.name : 'Aluno Removido', studentName: student ? student.name : 'Aluno Removido',
studentEmail: student ? student.email : '' studentEmail: student ? student.email : '',
description: desc
}; };
}) })
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
@ -791,6 +863,8 @@ app.get('/api/admin/dashboard', authenticateToken, requireAdmin, (req: Authentic
overdueSubscribers: totalOverdue, overdueSubscribers: totalOverdue,
canceledSubscribers: totalCanceled canceledSubscribers: totalCanceled
}, },
revenueData,
categoryData,
recentPayments, recentPayments,
webhookLogs webhookLogs
}); });
@ -1242,6 +1316,9 @@ app.post('/api/webhooks/asaas', (req: Request, res: Response) => {
} }
} else if (!isRefunded) { } else if (!isRefunded) {
// Se não achou e não é estorno/deleção, registra a fatura enviada pelo Asaas // Se não achou e não é estorno/deleção, registra a fatura enviada pelo Asaas
// Tenta achar o plano/curso da última compra do usuário para manter o histórico
const lastPayment = db.payments.filter(p => p.userId === user.id).sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
db.payments.push({ db.payments.push({
id: payment.id, // Usa o ID exato id: payment.id, // Usa o ID exato
userId: user.id, userId: user.id,
@ -1251,7 +1328,9 @@ app.post('/api/webhooks/asaas', (req: Request, res: Response) => {
invoiceUrl: payment.invoiceUrl || `https://sandbox.asaas.com/i/${payment.id}`, invoiceUrl: payment.invoiceUrl || `https://sandbox.asaas.com/i/${payment.id}`,
dueDate: payment.dueDate || new Date().toISOString().split('T')[0], dueDate: payment.dueDate || new Date().toISOString().split('T')[0],
paidAt: isPaid ? new Date().toISOString() : null, paidAt: isPaid ? new Date().toISOString() : null,
createdAt: new Date().toISOString() createdAt: new Date().toISOString(),
planId: lastPayment?.planId,
courseId: lastPayment?.courseId
}); });
} }

View File

@ -3,6 +3,7 @@ import {
TrendingUp, Users, AlertTriangle, XCircle, Search, TrendingUp, Users, AlertTriangle, XCircle, Search,
Settings, CheckCircle2, RefreshCw, Link as LinkIcon, ShieldCheck, HelpCircle, Eye, DollarSign Settings, CheckCircle2, RefreshCw, Link as LinkIcon, ShieldCheck, HelpCircle, Eye, DollarSign
} from 'lucide-react'; } from 'lucide-react';
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, Legend, Cell, PieChart, Pie } from 'recharts';
import { SubscriptionStatus } from '../types'; import { SubscriptionStatus } from '../types';
import { AdminPricingManager } from './AdminPricingManager'; import { AdminPricingManager } from './AdminPricingManager';
@ -42,6 +43,7 @@ interface PaymentLog {
dueDate: string; dueDate: string;
paidAt: string | null; paidAt: string | null;
createdAt: string; createdAt: string;
description?: string;
} }
interface DashboardStats { interface DashboardStats {
@ -62,6 +64,8 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
const [webhookLogs, setWebhookLogs] = useState<WebhookLog[]>([]); const [webhookLogs, setWebhookLogs] = useState<WebhookLog[]>([]);
const [students, setStudents] = useState<Student[]>([]); const [students, setStudents] = useState<Student[]>([]);
const [courses, setCourses] = useState<any[]>([]); const [courses, setCourses] = useState<any[]>([]);
const [revenueData, setRevenueData] = useState<any[]>([]);
const [categoryData, setCategoryData] = useState<any[]>([]);
const [expandedStudentId, setExpandedStudentId] = useState<string | null>(null); const [expandedStudentId, setExpandedStudentId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@ -178,6 +182,8 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
const data = await res.json(); const data = await res.json();
setStats(data.stats); setStats(data.stats);
setPayments(data.recentPayments); setPayments(data.recentPayments);
setRevenueData(data.revenueData || []);
setCategoryData(data.categoryData || []);
setWebhookLogs(data.webhookLogs); setWebhookLogs(data.webhookLogs);
} }
} catch (err) { } catch (err) {
@ -442,6 +448,53 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
{/* TAB 1: FINANCE / PAYMENTS */} {/* TAB 1: FINANCE / PAYMENTS */}
{activeTab === 'finance' && ( {activeTab === 'finance' && (
<div className="space-y-6"> <div className="space-y-6">
{/* GRÁFICOS DE RECEITA */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Gráfico de Faturamento por Dia */}
<div className="glass-card rounded-2xl p-6">
<h3 className="font-display font-bold text-sm text-white mb-4">Evolução do Faturamento (30 dias)</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={revenueData}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis dataKey="date" stroke="#9ca3af" fontSize={12} tickFormatter={(val) => new Date(val).toLocaleDateString('pt-BR', {day: '2-digit', month: '2-digit'})} />
<YAxis stroke="#9ca3af" fontSize={12} tickFormatter={(val) => `R$ ${val}`} />
<RechartsTooltip
contentStyle={{ backgroundColor: '#18181b', borderColor: '#3f3f46', color: '#fff' }}
formatter={(value: number) => [`R$ ${value.toFixed(2)}`, 'Receita']}
labelFormatter={(label) => new Date(label).toLocaleDateString('pt-BR')}
/>
<Line type="monotone" dataKey="value" stroke="#E50914" strokeWidth={3} dot={{ r: 4, fill: '#E50914' }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* Gráfico de Distribuição por Categoria/Plano */}
<div className="glass-card rounded-2xl p-6">
<h3 className="font-display font-bold text-sm text-white mb-4">Receita por Categoria/Módulo</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={categoryData} layout="vertical" margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" horizontal={false} />
<XAxis type="number" stroke="#9ca3af" fontSize={12} tickFormatter={(val) => `R$ ${val}`} />
<YAxis dataKey="name" type="category" stroke="#9ca3af" fontSize={12} width={100} />
<RechartsTooltip
contentStyle={{ backgroundColor: '#18181b', borderColor: '#3f3f46', color: '#fff' }}
formatter={(value: number) => [`R$ ${value.toFixed(2)}`, 'Total Recebido']}
/>
<Bar dataKey="value" fill="#E50914" radius={[0, 4, 4, 0]}>
{categoryData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={index % 2 === 0 ? '#E50914' : '#b91c1c'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
<div className="glass-card rounded-2xl overflow-hidden"> <div className="glass-card rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-white/5 flex items-center justify-between"> <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> <h3 className="font-display font-bold text-sm text-white">Transações e Cobranças Recorrentes</h3>
@ -453,6 +506,7 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
<thead className="bg-white/3 text-xs text-zinc-400 font-bold uppercase"> <thead className="bg-white/3 text-xs text-zinc-400 font-bold uppercase">
<tr> <tr>
<th className="px-6 py-3.5">Aluno</th> <th className="px-6 py-3.5">Aluno</th>
<th className="px-6 py-3.5">Produto</th>
<th className="px-6 py-3.5">Valor</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">Tipo</th>
<th className="px-6 py-3.5">Vencimento</th> <th className="px-6 py-3.5">Vencimento</th>
@ -472,6 +526,9 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
<div className="font-semibold text-white">{pay.studentName}</div> <div className="font-semibold text-white">{pay.studentName}</div>
<div className="text-xs text-zinc-500">{pay.studentEmail}</div> <div className="text-xs text-zinc-500">{pay.studentEmail}</div>
</td> </td>
<td className="px-6 py-4">
<div className="text-xs font-medium text-zinc-300">{pay.description || 'Assinatura Padrão'}</div>
</td>
<td className="px-6 py-4 font-bold text-zinc-200"> <td className="px-6 py-4 font-bold text-zinc-200">
R$ {pay.value.toFixed(2)} R$ {pay.value.toFixed(2)}
</td> </td>

View File

@ -98,6 +98,8 @@ export interface SubscriptionPayment {
id: string; id: string;
userId: string; userId: string;
value: number; value: number;
planId?: string;
courseId?: string;
status: 'PENDING' | 'RECEIVED' | 'CONFIRMED' | 'OVERDUE' | 'REFUNDED' | 'DELETED' | 'CANCELED'; status: 'PENDING' | 'RECEIVED' | 'CONFIRMED' | 'OVERDUE' | 'REFUNDED' | 'DELETED' | 'CANCELED';
billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO'; billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO';
invoiceUrl: string; invoiceUrl: string;