Polish: homework filters, users search/popup, admin comments
Homework (/curator/homework): - Search by student name/email - Filter by status (pending/reviewed) and course - Server-side pagination (20 per page) with URL params Users (/admin/users): - Search by name/email, filter by role - Hover popup on each row: enrolled courses + expiry dates + email - Pagination (20 per page) with URL params Comments (/admin/comments): - New admin page with all active comments - Search by author or text content - One-click delete (soft-delete) from the table - Pagination (30 per page) - Added "Комментарии" link to admin nav Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,115 +1,194 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import Link from "next/link";
|
||||
import { HomeworkFilters } from "@/components/admin/homework-filters";
|
||||
import { Suspense } from "react";
|
||||
|
||||
export default async function HomeworkListPage() {
|
||||
const submissions = await prisma.homeworkSubmission.findMany({
|
||||
orderBy: { submittedAt: "desc" },
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
feedbacks: { select: { id: true } },
|
||||
homework: {
|
||||
include: {
|
||||
lesson: {
|
||||
select: {
|
||||
title: true,
|
||||
module: { select: { title: true, course: { select: { title: true } } } },
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<{
|
||||
search?: string;
|
||||
status?: string;
|
||||
courseId?: string;
|
||||
page?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function HomeworkListPage({ searchParams }: Props) {
|
||||
const { search = "", status = "", courseId = "", page = "1" } = await searchParams;
|
||||
const currentPage = Math.max(1, parseInt(page) || 1);
|
||||
const skip = (currentPage - 1) * PAGE_SIZE;
|
||||
|
||||
// Build where clause
|
||||
const where = {
|
||||
...(search
|
||||
? {
|
||||
user: {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: "insensitive" as const } },
|
||||
{ email: { contains: search, mode: "insensitive" as const } },
|
||||
],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(courseId
|
||||
? {
|
||||
homework: {
|
||||
lesson: { module: { courseId } },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(status === "pending" ? { feedbacks: { none: {} } } : {}),
|
||||
...(status === "reviewed" ? { feedbacks: { some: {} } } : {}),
|
||||
};
|
||||
|
||||
const [submissions, total, courses] = await Promise.all([
|
||||
prisma.homeworkSubmission.findMany({
|
||||
where,
|
||||
orderBy: { submittedAt: "desc" },
|
||||
skip,
|
||||
take: PAGE_SIZE,
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
feedbacks: { select: { id: true } },
|
||||
homework: {
|
||||
include: {
|
||||
lesson: {
|
||||
select: {
|
||||
title: true,
|
||||
module: { select: { title: true, course: { select: { id: true, title: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
prisma.homeworkSubmission.count({ where }),
|
||||
prisma.course.findMany({ orderBy: { title: "asc" }, select: { id: true, title: true } }),
|
||||
]);
|
||||
|
||||
const pending = submissions.filter((s) => s.feedbacks.length === 0);
|
||||
const reviewed = submissions.filter((s) => s.feedbacks.length > 0);
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
const pendingCount = submissions.filter((s) => s.feedbacks.length === 0).length;
|
||||
|
||||
function pageUrl(p: number) {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set("search", search);
|
||||
if (status) params.set("status", status);
|
||||
if (courseId) params.set("courseId", courseId);
|
||||
params.set("page", String(p));
|
||||
return `/curator/homework?${params.toString()}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl">
|
||||
<h1 className="text-2xl font-bold mb-1">Домашние задания</h1>
|
||||
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
|
||||
{pending.length} ожидают проверки · {reviewed.length} проверено
|
||||
</p>
|
||||
<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)" }}>
|
||||
{total} {total === 1 ? "работа" : "работ"} · {pendingCount} ожидают проверки
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{pending.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||
Ожидают проверки
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{pending.map((s) => (
|
||||
<SubmissionRow key={s.id} submission={s} pending />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Suspense>
|
||||
<HomeworkFilters courses={courses} />
|
||||
</Suspense>
|
||||
|
||||
{reviewed.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||
Проверено
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{reviewed.map((s) => (
|
||||
<SubmissionRow key={s.id} submission={s} pending={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submissions.length === 0 && (
|
||||
{submissions.length === 0 ? (
|
||||
<div className="card-aubade p-10 text-center">
|
||||
<p className="text-3xl mb-2">📭</p>
|
||||
<p className="font-bold">Работ пока нет</p>
|
||||
<p className="font-bold">Ничего не найдено</p>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||
Попробуйте изменить фильтры
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{submissions.map((s) => {
|
||||
const isPending = s.feedbacks.length === 0;
|
||||
return (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/curator/homework/${s.id}`}
|
||||
className="flex items-center gap-4 px-4 py-3 text-sm transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
border: `2px solid ${isPending ? "var(--foreground)" : "var(--border)"}`,
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{s.user.name}</p>
|
||||
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>
|
||||
{s.homework.lesson.module.course.title} · {s.homework.lesson.title}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{s.user.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span
|
||||
className="text-xs px-2 py-0.5"
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
background: isPending ? "var(--foreground)" : "transparent",
|
||||
color: isPending ? "var(--background)" : "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
{isPending ? "Новое" : "Проверено"}
|
||||
</span>
|
||||
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||
{new Date(s.submittedAt).toLocaleDateString("ru-RU")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1 mt-5">
|
||||
{currentPage > 1 && (
|
||||
<Link href={pageUrl(currentPage - 1)} className="btn-aubade px-3 py-1 text-xs">←</Link>
|
||||
)}
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||||
.reduce<(number | "…")[]>((acc, p, i, arr) => {
|
||||
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
|
||||
acc.push(p);
|
||||
return acc;
|
||||
}, [])
|
||||
.map((p, i) =>
|
||||
p === "…" ? (
|
||||
<span key={`e${i}`} className="px-2 text-xs" style={{ color: "var(--muted-foreground)" }}>…</span>
|
||||
) : (
|
||||
<Link
|
||||
key={p}
|
||||
href={pageUrl(p as number)}
|
||||
className="px-3 py-1 text-xs"
|
||||
style={{
|
||||
border: "2px solid var(--border)",
|
||||
background: p === currentPage ? "var(--foreground)" : "transparent",
|
||||
color: p === currentPage ? "var(--background)" : "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
{currentPage < totalPages && (
|
||||
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs">→</Link>
|
||||
)}
|
||||
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
стр. {currentPage} из {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmissionRow({
|
||||
submission,
|
||||
pending,
|
||||
}: {
|
||||
submission: {
|
||||
id: string;
|
||||
submittedAt: Date;
|
||||
user: { name: string; email: string };
|
||||
homework: {
|
||||
lesson: {
|
||||
title: string;
|
||||
module: { title: string; course: { title: string } };
|
||||
};
|
||||
};
|
||||
};
|
||||
pending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/curator/homework/${submission.id}`}
|
||||
className="flex items-center gap-4 px-4 py-3 text-sm transition-colors"
|
||||
style={{ border: `2px solid ${pending ? "var(--foreground)" : "var(--border)"}`, display: "flex" }}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{submission.user.name}</p>
|
||||
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>
|
||||
{submission.homework.lesson.module.course.title} · {submission.homework.lesson.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span
|
||||
className="text-xs px-2 py-0.5"
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
background: pending ? "var(--foreground)" : "transparent",
|
||||
color: pending ? "var(--background)" : "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
{pending ? "Новое" : "Проверено"}
|
||||
</span>
|
||||
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||
{new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user