feat: dashboard charts with recharts, mrr fix and dynamic payment product tracking
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 56s
Details
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 56s
Details
This commit is contained in:
parent
5e1218148b
commit
5bbe00b2f3
|
|
@ -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": {
|
||||||
|
|
|
||||||
91
server.ts
91
server.ts
|
|
@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue