Replace admin dashboard stub with real stats

- 4 stat cards: students (+monthly), courses (published), active enrollments (expiring alert), homework pending
- Recent enrollments list (last 8)
- Top courses by enrollment count
- Activity counters: total lessons completed, total homework submitted
- All cards link to relevant admin pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 14:58:46 +05:00
parent b40d518b74
commit ec51dd34bb
+192 -17
View File
@@ -1,27 +1,202 @@
import { prisma } from "@/lib/prisma";
import Link from "next/link"; import Link from "next/link";
export default function AdminDashboard() { export default async function AdminDashboard() {
const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const [
totalStudents,
newStudentsMonth,
totalCourses,
publishedCourses,
activeEnrollments,
expiringWeek,
homeworkPending,
homeworkTotal,
progressTotal,
] = await Promise.all([
prisma.user.count({ where: { role: "student" } }),
prisma.user.count({ where: { role: "student", createdAt: { gte: monthAgo } } }),
prisma.course.count(),
prisma.course.count({ where: { published: true } }),
prisma.courseEnrollment.count({
where: { OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
}),
prisma.courseEnrollment.count({
where: { expiresAt: { gt: now, lte: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) } },
}),
prisma.homeworkSubmission.count({ where: { feedbacks: { none: {} } } }),
prisma.homeworkSubmission.count(),
prisma.lessonProgress.count(),
]);
// Recent enrollments
const recentEnrollments = await prisma.courseEnrollment.findMany({
orderBy: { enrolledAt: "desc" },
take: 8,
include: {
user: { select: { name: true, email: true } },
course: { select: { title: true } },
},
});
// Most active courses (by enrollment count)
const topCourses = await prisma.course.findMany({
where: { published: true },
include: { _count: { select: { enrollments: true, modules: true } } },
orderBy: { enrollments: { _count: "desc" } },
take: 5,
});
return ( return (
<div className="p-8"> <div className="p-8 max-w-5xl">
<h1 className="text-2xl font-semibold text-slate-800 mb-1">Обзор</h1> <h1 className="text-2xl font-bold mb-1">Обзор</h1>
<p className="text-slate-500 mb-8">Управление платформой Second Brain.</p> <p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> Платформа Second Brain · {now.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" })}
<Link href="/admin/courses" className="bg-white rounded-2xl border border-slate-200 p-6 hover:border-amber-300 transition-colors"> </p>
<p className="text-3xl mb-2">📚</p>
<p className="font-medium text-slate-800">Курсы</p> {/* Stats grid */}
<p className="text-sm text-slate-400 mt-1">Управление контентом</p> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<StatCard
label="Студентов"
value={totalStudents}
sub={`+${newStudentsMonth} за месяц`}
href="/admin/users"
/>
<StatCard
label="Курсов"
value={totalCourses}
sub={`${publishedCourses} опубликовано`}
href="/admin/courses"
/>
<StatCard
label="Активных доступов"
value={activeEnrollments}
sub={expiringWeek > 0 ? `${expiringWeek} истекает на неделе` : "нет истекающих"}
subAccent={expiringWeek > 0}
href="/admin/courses"
/>
<StatCard
label="ДЗ на проверку"
value={homeworkPending}
sub={`${homeworkTotal} всего сдано`}
subAccent={homeworkPending > 0}
href="/curator/homework"
/>
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* Recent enrollments */}
<div className="card-aubade p-5">
<div className="flex items-center justify-between mb-4">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Последние зачисления
</p>
<Link href="/admin/users" className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
Все
</Link> </Link>
<Link href="/admin/users" className="bg-white rounded-2xl border border-slate-200 p-6 hover:border-amber-300 transition-colors"> </div>
<p className="text-3xl mb-2">👥</p> {recentEnrollments.length === 0 ? (
<p className="font-medium text-slate-800">Пользователи</p> <p className="text-sm" style={{ color: "var(--muted-foreground)" }}>Нет зачислений</p>
<p className="text-sm text-slate-400 mt-1">Управление доступом</p> ) : (
<div className="space-y-2">
{recentEnrollments.map((e) => (
<div key={`${e.userId}-${e.courseId}`} className="flex items-center gap-3 text-sm">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{e.user.name}</p>
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>{e.course.title}</p>
</div>
<span className="text-xs shrink-0" style={{ color: "var(--muted-foreground)" }}>
{new Date(e.enrolledAt).toLocaleDateString("ru-RU")}
</span>
</div>
))}
</div>
)}
</div>
{/* Top courses + progress stat */}
<div className="space-y-6">
<div className="card-aubade p-5">
<div className="flex items-center justify-between mb-4">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Популярные курсы
</p>
<Link href="/admin/courses" className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
Все
</Link> </Link>
<div className="bg-white rounded-2xl border border-slate-200 p-6 opacity-50"> </div>
<p className="text-3xl mb-2">📊</p> {topCourses.length === 0 ? (
<p className="font-medium text-slate-800">Аналитика</p> <p className="text-sm" style={{ color: "var(--muted-foreground)" }}>Нет курсов</p>
<p className="text-sm text-slate-400 mt-1">Этап 10</p> ) : (
<div className="space-y-3">
{topCourses.map((c) => (
<div key={c.id} className="flex items-center gap-3 text-sm">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{c.title}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{c._count.modules} модулей
</p>
</div>
<div className="text-right shrink-0">
<p className="font-bold">{c._count.enrollments}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>студентов</p>
</div>
</div>
))}
</div>
)}
</div>
<div className="card-aubade p-5">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Активность
</p>
<div className="flex items-end gap-6">
<div>
<p className="text-3xl font-bold">{progressTotal}</p>
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>уроков пройдено</p>
</div>
<div>
<p className="text-3xl font-bold">{homeworkTotal}</p>
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>работ сдано</p>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
function StatCard({
label,
value,
sub,
subAccent,
href,
}: {
label: string;
value: number;
sub?: string;
subAccent?: boolean;
href?: string;
}) {
const content = (
<div className="card-aubade p-4">
<p className="text-3xl font-bold">{value}</p>
<p className="text-xs font-bold uppercase tracking-widest mt-1" style={{ color: "var(--muted-foreground)" }}>
{label}
</p>
{sub && (
<p className="text-xs mt-1.5" style={{ color: subAccent ? "oklch(0.577 0.245 27.325)" : "var(--muted-foreground)" }}>
{sub}
</p>
)}
</div>
);
return href ? <Link href={href}>{content}</Link> : content;
}