diff --git a/package.json b/package.json index 6e7662e..7b48c61 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "motion": "^12.23.24", "react": "^19.0.1", "react-dom": "^19.0.1", + "recharts": "^2.15.0", "vite": "^6.2.3" }, "devDependencies": { diff --git a/server.ts b/server.ts index 0633a34..9e8fa67 100644 --- a/server.ts +++ b/server.ts @@ -514,7 +514,8 @@ app.post('/api/courses/:id/unlock', authenticateToken, checkoutRateLimiter, asyn invoiceUrl: asaasPayment.invoiceUrl, dueDate: asaasPayment.dueDate || new Date().toISOString().split('T')[0], paidAt: null, - createdAt: new Date().toISOString() + createdAt: new Date().toISOString(), + courseId: courseId }; db.payments.push(newPayment); saveDb(db); @@ -726,7 +727,8 @@ app.post('/api/subscription/subscribe', authenticateToken, async (req: Authentic invoiceUrl: invoiceUrlStr, dueDate: asaasDueDate, paidAt: statusAsaas === 'CONFIRMED' ? new Date().toISOString() : null, - createdAt: new Date().toISOString() + createdAt: new Date().toISOString(), + planId: planId }; 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 totalTrial = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'TRIAL').length; - // MRR = Active Subscribers * 49.90 - const mrr = totalActive * 49.90; + // MRR Calculation + 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 = {}; + const categoryRevenue: Record = {}; + + 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 const recentPayments = db.payments .map(p => { 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 { ...p, 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()) @@ -791,6 +863,8 @@ app.get('/api/admin/dashboard', authenticateToken, requireAdmin, (req: Authentic overdueSubscribers: totalOverdue, canceledSubscribers: totalCanceled }, + revenueData, + categoryData, recentPayments, webhookLogs }); @@ -1242,6 +1316,9 @@ app.post('/api/webhooks/asaas', (req: Request, res: Response) => { } } else if (!isRefunded) { // 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({ id: payment.id, // Usa o ID exato 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}`, dueDate: payment.dueDate || new Date().toISOString().split('T')[0], paidAt: isPaid ? new Date().toISOString() : null, - createdAt: new Date().toISOString() + createdAt: new Date().toISOString(), + planId: lastPayment?.planId, + courseId: lastPayment?.courseId }); } diff --git a/src/components/AdminDashboard.tsx b/src/components/AdminDashboard.tsx index 294f21e..6b4fe74 100644 --- a/src/components/AdminDashboard.tsx +++ b/src/components/AdminDashboard.tsx @@ -3,6 +3,7 @@ import { TrendingUp, Users, AlertTriangle, XCircle, Search, Settings, CheckCircle2, RefreshCw, Link as LinkIcon, ShieldCheck, HelpCircle, Eye, DollarSign } 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 { AdminPricingManager } from './AdminPricingManager'; @@ -42,6 +43,7 @@ interface PaymentLog { dueDate: string; paidAt: string | null; createdAt: string; + description?: string; } interface DashboardStats { @@ -62,6 +64,8 @@ export default function AdminDashboard({ token }: AdminDashboardProps) { const [webhookLogs, setWebhookLogs] = useState([]); const [students, setStudents] = useState([]); const [courses, setCourses] = useState([]); + const [revenueData, setRevenueData] = useState([]); + const [categoryData, setCategoryData] = useState([]); const [expandedStudentId, setExpandedStudentId] = useState(null); const [searchQuery, setSearchQuery] = useState(''); @@ -178,6 +182,8 @@ export default function AdminDashboard({ token }: AdminDashboardProps) { const data = await res.json(); setStats(data.stats); setPayments(data.recentPayments); + setRevenueData(data.revenueData || []); + setCategoryData(data.categoryData || []); setWebhookLogs(data.webhookLogs); } } catch (err) { @@ -442,6 +448,53 @@ export default function AdminDashboard({ token }: AdminDashboardProps) { {/* TAB 1: FINANCE / PAYMENTS */} {activeTab === 'finance' && (
+ + {/* GRÁFICOS DE RECEITA */} +
+ {/* Gráfico de Faturamento por Dia */} +
+

Evolução do Faturamento (30 dias)

+
+ + + + new Date(val).toLocaleDateString('pt-BR', {day: '2-digit', month: '2-digit'})} /> + `R$ ${val}`} /> + [`R$ ${value.toFixed(2)}`, 'Receita']} + labelFormatter={(label) => new Date(label).toLocaleDateString('pt-BR')} + /> + + + +
+
+ + {/* Gráfico de Distribuição por Categoria/Plano */} +
+

Receita por Categoria/Módulo

+
+ + + + `R$ ${val}`} /> + + [`R$ ${value.toFixed(2)}`, 'Total Recebido']} + /> + + {categoryData.map((entry, index) => ( + + ))} + + + +
+
+
+

Transações e Cobranças Recorrentes

@@ -453,6 +506,7 @@ export default function AdminDashboard({ token }: AdminDashboardProps) { Aluno + Produto Valor Tipo Vencimento @@ -472,6 +526,9 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
{pay.studentName}
{pay.studentEmail}
+ +
{pay.description || 'Assinatura Padrão'}
+ R$ {pay.value.toFixed(2)} diff --git a/src/types.ts b/src/types.ts index 4c399a2..6b94687 100644 --- a/src/types.ts +++ b/src/types.ts @@ -98,6 +98,8 @@ export interface SubscriptionPayment { id: string; userId: string; value: number; + planId?: string; + courseId?: string; status: 'PENDING' | 'RECEIVED' | 'CONFIRMED' | 'OVERDUE' | 'REFUNDED' | 'DELETED' | 'CANCELED'; billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO'; invoiceUrl: string;