Add admin quiz attempts viewer
- /admin/quizzes: list all quizzes with question and attempt counts - /admin/quizzes/[quizId]: view all student attempts with answers per question - Add "Тесты" link to admin sidebar navigation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ quizId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminQuizAttemptsPage({ params }: Props) {
|
||||||
|
const { quizId } = await params;
|
||||||
|
|
||||||
|
const quiz = await prisma.quiz.findUnique({
|
||||||
|
where: { id: quizId },
|
||||||
|
include: {
|
||||||
|
questions: { orderBy: { order: "asc" } },
|
||||||
|
attempts: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
user: { select: { name: true, email: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lesson: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
module: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
course: { select: { id: true, title: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!quiz) notFound();
|
||||||
|
|
||||||
|
const courseId = quiz.lesson.module.course.id;
|
||||||
|
const moduleId = quiz.lesson.module.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-4xl">
|
||||||
|
<nav
|
||||||
|
className="text-xs mb-6 uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
<Link href="/admin/quizzes" className="hover:underline">
|
||||||
|
Тесты
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<span style={{ color: "var(--foreground)" }}>{quiz.lesson.title}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-lg font-bold">{quiz.lesson.title}</h1>
|
||||||
|
<p className="text-xs mt-1 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{quiz.lesson.module.course.title} · {quiz.questions.length} вопросов · {quiz.attempts.length} ответов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{quiz.attempts.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Ответов пока нет
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{quiz.attempts.map((attempt) => {
|
||||||
|
const answers = attempt.answers as Record<string, string>;
|
||||||
|
const date = new Date(attempt.createdAt).toLocaleString("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={attempt.id}
|
||||||
|
className="px-4 py-4 space-y-3"
|
||||||
|
style={{ border: "2px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{attempt.user.name}</p>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{attempt.user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-1" style={{ borderTop: "1px solid var(--border)" }}>
|
||||||
|
{quiz.questions.map((q, idx) => (
|
||||||
|
<div key={q.id}>
|
||||||
|
<p
|
||||||
|
className="text-xs font-medium mb-0.5"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
{idx + 1}. {q.text}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">
|
||||||
|
{answers[q.id]?.trim() || <span style={{ color: "var(--muted-foreground)" }}>—</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const metadata = { title: "Тесты" };
|
||||||
|
|
||||||
|
export default async function AdminQuizzesPage() {
|
||||||
|
const quizzes = await prisma.quiz.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
_count: { select: { questions: true, attempts: true } },
|
||||||
|
lesson: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
module: {
|
||||||
|
select: {
|
||||||
|
course: { select: { title: true, slug: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1
|
||||||
|
className="text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Тесты
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{quizzes.length} тестов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{quizzes.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Тестов нет
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0" style={{ border: "2px solid var(--border)" }}>
|
||||||
|
{quizzes.map((quiz) => (
|
||||||
|
<Link
|
||||||
|
key={quiz.id}
|
||||||
|
href={`/admin/quizzes/${quiz.id}`}
|
||||||
|
className="flex items-center gap-4 px-4 py-3 hover:[background:var(--muted)] transition-colors"
|
||||||
|
style={{ borderBottom: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">
|
||||||
|
{quiz.lesson.title}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-xs mt-0.5 truncate"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
{quiz.lesson.module.course.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 shrink-0 text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
<span>{quiz._count.questions} вопр.</span>
|
||||||
|
<span
|
||||||
|
className="font-bold"
|
||||||
|
style={{ color: quiz._count.attempts > 0 ? "var(--foreground)" : undefined }}
|
||||||
|
>
|
||||||
|
{quiz._count.attempts} ответов
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--muted-foreground)" }}>→</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ const links = [
|
|||||||
{ href: "/admin/categories", label: "Категории" },
|
{ href: "/admin/categories", label: "Категории" },
|
||||||
{ href: "/admin/users", label: "Пользователи" },
|
{ href: "/admin/users", label: "Пользователи" },
|
||||||
{ href: "/curator/homework", label: "ДЗ на проверку" },
|
{ href: "/curator/homework", label: "ДЗ на проверку" },
|
||||||
|
{ href: "/admin/quizzes", label: "Тесты" },
|
||||||
{ href: "/admin/comments", label: "Комментарии" },
|
{ href: "/admin/comments", label: "Комментарии" },
|
||||||
{ href: "/admin/import-export", label: "Импорт / Экспорт" },
|
{ href: "/admin/import-export", label: "Импорт / Экспорт" },
|
||||||
{ href: "/admin/settings", label: "Настройки" },
|
{ href: "/admin/settings", label: "Настройки" },
|
||||||
|
|||||||
Reference in New Issue
Block a user