Dashboard
O dashboard administrativo exibe métricas em tempo real, estatísticas financeiras e atividades recentes da plataforma.
Visão Geral
Métricas Principais
Stats Cards
typescript
// get-dashboard-stats.use-case.ts
interface DashboardStats {
users: {
total: number;
activeToday: number;
newToday: number;
growth: number; // % em relação a ontem
};
financial: {
revenueToday: bigint;
profitToday: bigint;
depositsToday: bigint;
withdrawalsToday: bigint;
pendingWithdrawals: number;
};
games: {
casesOpenedToday: number;
battlesToday: number;
upgradesToday: number;
totalVolume: bigint;
};
topItems: {
mostDropped: Item;
highestValue: Item;
mostOpened: Case;
};
}Implementação
typescript
// get-dashboard-stats.use-case.ts
@Injectable()
export class GetDashboardStatsUseCase {
async execute(): Promise<DashboardStats> {
const today = startOfDay(new Date());
const yesterday = subDays(today, 1);
const [
totalUsers,
activeToday,
newToday,
activeYesterday,
revenueToday,
depositsToday,
withdrawalsToday,
pendingWithdrawals,
casesOpenedToday,
battlesToday,
] = await Promise.all([
this.userRepository.count(),
this.userRepository.countActiveAfter(today),
this.userRepository.countCreatedAfter(today),
this.userRepository.countActiveAfter(yesterday),
this.transactionRepository.sumRevenueAfter(today),
this.depositRepository.sumConfirmedAfter(today),
this.withdrawalRepository.sumCompletedAfter(today),
this.withdrawalRepository.countByStatus('PENDING'),
this.caseOpeningRepository.countAfter(today),
this.battleRepository.countAfter(today),
]);
// Calcular crescimento
const growth = activeYesterday > 0
? ((activeToday - activeYesterday) / activeYesterday) * 100
: 0;
// Calcular lucro
const profitToday = revenueToday - withdrawalsToday;
return {
users: {
total: totalUsers,
activeToday,
newToday,
growth: Math.round(growth * 100) / 100,
},
financial: {
revenueToday,
profitToday,
depositsToday,
withdrawalsToday,
pendingWithdrawals,
},
games: {
casesOpenedToday,
battlesToday,
upgradesToday: 0, // TODO
totalVolume: revenueToday,
},
// ...
};
}
}Gráfico de Receita
Endpoint
http
GET /api/admin/dashboard/revenue-chart?period=7dResponse
json
{
"data": [
{ "date": "2024-01-10", "revenue": 15000, "profit": 12000, "deposits": 20000, "withdrawals": 8000 },
{ "date": "2024-01-11", "revenue": 18000, "profit": 14500, "deposits": 25000, "withdrawals": 10500 },
{ "date": "2024-01-12", "revenue": 12000, "profit": 9000, "deposits": 15000, "withdrawals": 6000 }
],
"summary": {
"totalRevenue": 45000,
"totalProfit": 35500,
"avgDailyRevenue": 15000,
"growthRate": 12.5
}
}Implementação
typescript
// get-revenue-chart.use-case.ts
@Injectable()
export class GetRevenueChartUseCase {
async execute(period: '7d' | '30d' | '90d'): Promise<RevenueChart> {
const days = period === '7d' ? 7 : period === '30d' ? 30 : 90;
const startDate = subDays(new Date(), days);
// Query agregada por dia
const data = await this.prisma.$queryRaw<RevenueDataPoint[]>`
SELECT
DATE(created_at) as date,
SUM(CASE WHEN type IN ('CASE_OPEN', 'BATTLE_ENTRY', 'UPGRADE') THEN amount_cents ELSE 0 END) as revenue,
SUM(CASE WHEN type = 'DEPOSIT' THEN amount_cents ELSE 0 END) as deposits,
SUM(CASE WHEN type = 'WITHDRAWAL' THEN amount_cents ELSE 0 END) as withdrawals
FROM "Transaction"
WHERE created_at >= ${startDate}
GROUP BY DATE(created_at)
ORDER BY date ASC
`;
// Calcular lucro por dia
const chartData = data.map((d) => ({
date: d.date,
revenue: Number(d.revenue),
profit: Number(d.revenue) - Number(d.withdrawals),
deposits: Number(d.deposits),
withdrawals: Number(d.withdrawals),
}));
// Calcular resumo
const totalRevenue = chartData.reduce((sum, d) => sum + d.revenue, 0);
const totalProfit = chartData.reduce((sum, d) => sum + d.profit, 0);
return {
data: chartData,
summary: {
totalRevenue,
totalProfit,
avgDailyRevenue: totalRevenue / days,
growthRate: this.calculateGrowthRate(chartData),
},
};
}
}Frontend Component
tsx
// components/dashboard/revenue-chart.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
export function RevenueChart() {
const [period, setPeriod] = useState<'7d' | '30d' | '90d'>('7d');
const { data, isLoading } = useQuery({
queryKey: ['revenue-chart', period],
queryFn: () => fetchRevenueChart(period),
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Receita e Lucro</CardTitle>
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7d">7 dias</SelectItem>
<SelectItem value="30d">30 dias</SelectItem>
<SelectItem value="90d">90 dias</SelectItem>
</SelectContent>
</Select>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-80" />
) : (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={data?.data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip formatter={(v) => `R$ ${(v / 100).toFixed(2)}`} />
<Legend />
<Line
type="monotone"
dataKey="revenue"
name="Receita"
stroke="#22c55e"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="profit"
name="Lucro"
stroke="#3b82f6"
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}Atividades Recentes
Endpoint
http
GET /api/admin/dashboard/recent-activity?limit=20Response
json
{
"activities": [
{
"id": "1",
"type": "CASE_OPEN",
"user": { "id": "123", "username": "player1" },
"description": "Abriu Caixa Premium",
"value": 1500,
"item": { "name": "AWP | Dragon Lore", "rarity": "EXTRAORDINARY" },
"createdAt": "2024-01-15T10:30:00Z"
},
{
"id": "2",
"type": "DEPOSIT",
"user": { "id": "456", "username": "player2" },
"description": "Depósito via PIX",
"value": 10000,
"createdAt": "2024-01-15T10:29:00Z"
},
{
"id": "3",
"type": "WITHDRAWAL_REQUEST",
"user": { "id": "789", "username": "player3" },
"description": "Solicitou saque",
"value": 5000,
"createdAt": "2024-01-15T10:28:00Z"
}
]
}Frontend Component
tsx
// components/dashboard/recent-activity.tsx
export function RecentActivity() {
const { data, isLoading } = useQuery({
queryKey: ['recent-activity'],
queryFn: fetchRecentActivity,
refetchInterval: 10000, // Atualizar a cada 10s
});
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ActivityIcon className="h-5 w-5" />
Atividades Recentes
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data?.activities.map((activity) => (
<div
key={activity.id}
className="flex items-center justify-between border-b pb-4"
>
<div className="flex items-center gap-3">
<ActivityIcon type={activity.type} />
<div>
<p className="font-medium">{activity.user.username}</p>
<p className="text-sm text-muted-foreground">
{activity.description}
</p>
</div>
</div>
<div className="text-right">
<p className="font-medium">
R$ {(activity.value / 100).toFixed(2)}
</p>
<p className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(activity.createdAt))}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}Stats Cards Component
tsx
// components/dashboard/stats-cards.tsx
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Users,
DollarSign,
TrendingUp,
Package,
ArrowUpRight,
ArrowDownRight,
} from 'lucide-react';
interface StatsCardsProps {
stats: DashboardStats;
}
export function StatsCards({ stats }: StatsCardsProps) {
const cards = [
{
title: 'Total Usuários',
value: stats.users.total.toLocaleString(),
change: `+${stats.users.newToday} hoje`,
changeType: 'positive',
icon: Users,
},
{
title: 'Receita Hoje',
value: `R$ ${(Number(stats.financial.revenueToday) / 100).toFixed(2)}`,
change: `${stats.users.growth > 0 ? '+' : ''}${stats.users.growth}%`,
changeType: stats.users.growth >= 0 ? 'positive' : 'negative',
icon: DollarSign,
},
{
title: 'Lucro Hoje',
value: `R$ ${(Number(stats.financial.profitToday) / 100).toFixed(2)}`,
change: 'vs. depósitos',
changeType: 'neutral',
icon: TrendingUp,
},
{
title: 'Caixas Abertas',
value: stats.games.casesOpenedToday.toLocaleString(),
change: 'hoje',
changeType: 'neutral',
icon: Package,
},
];
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{cards.map((card) => (
<Card key={card.title}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{card.title}
</CardTitle>
<card.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{card.value}</div>
<p className="text-xs text-muted-foreground flex items-center gap-1">
{card.changeType === 'positive' && (
<ArrowUpRight className="h-3 w-3 text-green-500" />
)}
{card.changeType === 'negative' && (
<ArrowDownRight className="h-3 w-3 text-red-500" />
)}
{card.change}
</p>
</CardContent>
</Card>
))}
</div>
);
}Dashboard Page
tsx
// app/(dashboard)/page.tsx
import { StatsCards } from '@/components/dashboard/stats-cards';
import { RevenueChart } from '@/components/dashboard/revenue-chart';
import { RecentActivity } from '@/components/dashboard/recent-activity';
import { PendingWithdrawals } from '@/components/dashboard/pending-withdrawals';
import { ActiveBattles } from '@/components/dashboard/active-battles';
export default async function DashboardPage() {
const stats = await fetchDashboardStats();
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Dashboard</h1>
{/* Stats Cards */}
<StatsCards stats={stats} />
{/* Gráficos */}
<div className="grid gap-6 lg:grid-cols-2">
<RevenueChart />
<RecentActivity />
</div>
{/* Pendências */}
<div className="grid gap-6 lg:grid-cols-2">
<PendingWithdrawals />
<ActiveBattles />
</div>
</div>
);
}Alertas
tsx
// components/dashboard/alerts.tsx
export function DashboardAlerts() {
const { data: alerts } = useQuery({
queryKey: ['dashboard-alerts'],
queryFn: fetchAlerts,
refetchInterval: 30000,
});
if (!alerts?.length) return null;
return (
<div className="space-y-2">
{alerts.map((alert) => (
<Alert
key={alert.id}
variant={alert.level === 'CRITICAL' ? 'destructive' : 'default'}
>
<AlertCircle className="h-4 w-4" />
<AlertTitle>{alert.title}</AlertTitle>
<AlertDescription>{alert.message}</AlertDescription>
</Alert>
))}
</div>
);
}