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";
|
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">
|
||||||
</Link>
|
<StatCard
|
||||||
<Link href="/admin/users" className="bg-white rounded-2xl border border-slate-200 p-6 hover:border-amber-300 transition-colors">
|
label="Студентов"
|
||||||
<p className="text-3xl mb-2">👥</p>
|
value={totalStudents}
|
||||||
<p className="font-medium text-slate-800">Пользователи</p>
|
sub={`+${newStudentsMonth} за месяц`}
|
||||||
<p className="text-sm text-slate-400 mt-1">Управление доступом</p>
|
href="/admin/users"
|
||||||
</Link>
|
/>
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 p-6 opacity-50">
|
<StatCard
|
||||||
<p className="text-3xl mb-2">📊</p>
|
label="Курсов"
|
||||||
<p className="font-medium text-slate-800">Аналитика</p>
|
value={totalCourses}
|
||||||
<p className="text-sm text-slate-400 mt-1">Этап 10</p>
|
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>
|
||||||
|
</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>
|
||||||
|
{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>
|
</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