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:
@@ -1,27 +1,202 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
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 (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-semibold text-slate-800 mb-1">Обзор</h1>
|
||||
<p className="text-slate-500 mb-8">Управление платформой Second Brain.</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Link href="/admin/courses" className="bg-white rounded-2xl border border-slate-200 p-6 hover:border-amber-300 transition-colors">
|
||||
<p className="text-3xl mb-2">📚</p>
|
||||
<p className="font-medium text-slate-800">Курсы</p>
|
||||
<p className="text-sm text-slate-400 mt-1">Управление контентом</p>
|
||||
<div className="p-8 max-w-5xl">
|
||||
<h1 className="text-2xl font-bold mb-1">Обзор</h1>
|
||||
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
|
||||
Платформа Second Brain · {now.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" })}
|
||||
</p>
|
||||
|
||||
{/* Stats grid */}
|
||||
<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 href="/admin/users" className="bg-white rounded-2xl border border-slate-200 p-6 hover:border-amber-300 transition-colors">
|
||||
<p className="text-3xl mb-2">👥</p>
|
||||
<p className="font-medium text-slate-800">Пользователи</p>
|
||||
<p className="text-sm text-slate-400 mt-1">Управление доступом</p>
|
||||
</div>
|
||||
{recentEnrollments.length === 0 ? (
|
||||
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>Нет зачислений</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>
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-6 opacity-50">
|
||||
<p className="text-3xl mb-2">📊</p>
|
||||
<p className="font-medium text-slate-800">Аналитика</p>
|
||||
<p className="text-sm text-slate-400 mt-1">Этап 10</p>
|
||||
</div>
|
||||
{topCourses.length === 0 ? (
|
||||
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>Нет курсов</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>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user