Dashboard tiles and the curator CTA pointed to /curator/homework without a status filter, so the default all-submissions list (newest first) buried old PENDING items pages deep — 19 June submissions sat unseen for 6 weeks while the top of the list showed freshly approved work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
232 lines
8.9 KiB
TypeScript
232 lines
8.9 KiB
TypeScript
import { prisma } from "@/lib/prisma";
|
|
import Link from "next/link";
|
|
|
|
export default async function AdminDashboard() {
|
|
const now = new Date();
|
|
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
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,
|
|
balanceAggregate,
|
|
activeLast24h,
|
|
] = 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: { status: { in: ["PENDING", "REVIEWING"] } } }),
|
|
prisma.homeworkSubmission.count(),
|
|
prisma.lessonProgress.count(),
|
|
prisma.balanceTransaction.aggregate({ _sum: { amount: true } }),
|
|
prisma.session.findMany({
|
|
where: { createdAt: { gte: dayAgo } },
|
|
select: { userId: true },
|
|
distinct: ["userId"],
|
|
}).then((rows) => rows.length),
|
|
]);
|
|
|
|
const totalBalance = Number(balanceAggregate._sum.amount ?? 0);
|
|
|
|
// 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 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?status=pending"
|
|
/>
|
|
</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>
|
|
|
|
<Link href="/admin/users?balance=nonzero" className="card-aubade p-5 block">
|
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
|
На балансах
|
|
</p>
|
|
<p className="text-3xl font-bold">
|
|
{totalBalance.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
|
</p>
|
|
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>сумма по всем пользователям</p>
|
|
</Link>
|
|
|
|
<div className="card-aubade p-5">
|
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
|
Авторизации за 24 часа
|
|
</p>
|
|
<p className="text-3xl font-bold">{activeLast24h}</p>
|
|
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>уникальных пользователей</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatCard({
|
|
label,
|
|
value,
|
|
sub,
|
|
subAccent,
|
|
href,
|
|
}: {
|
|
label: string;
|
|
value: number | string;
|
|
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;
|
|
}
|