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:
2026-04-08 13:00:57 +05:00
parent dd46a10c20
commit d0ba4bf909
9 changed files with 874 additions and 152 deletions
+18
View File
@@ -0,0 +1,18 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function adminDeleteComment(commentId: string) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
await prisma.lessonComment.update({
where: { id: commentId },
data: { deleted: true },
});
revalidatePath("/admin/comments");
}
+114
View File
@@ -0,0 +1,114 @@
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import { Suspense } from "react";
import { CommentsTable } from "@/components/admin/comments-table";
export const metadata = { title: "Комментарии" };
const PAGE_SIZE = 30;
interface Props {
searchParams: Promise<{ page?: string; search?: string }>;
}
export default async function AdminCommentsPage({ searchParams }: Props) {
const { page = "1", search = "" } = await searchParams;
const currentPage = Math.max(1, parseInt(page) || 1);
const skip = (currentPage - 1) * PAGE_SIZE;
const where = {
deleted: false,
...(search
? {
OR: [
{ user: { name: { contains: search, mode: "insensitive" as const } } },
{ user: { email: { contains: search, mode: "insensitive" as const } } },
{ text: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
};
const [comments, total] = await Promise.all([
prisma.lessonComment.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: PAGE_SIZE,
include: {
user: { select: { id: true, name: true, email: true } },
lesson: {
select: {
id: true,
title: true,
module: {
select: {
course: { select: { slug: true, title: true } },
},
},
},
},
},
}),
prisma.lessonComment.count({ where }),
]);
const totalPages = Math.ceil(total / PAGE_SIZE);
function pageUrl(p: number) {
const params = new URLSearchParams();
if (search) params.set("search", search);
params.set("page", String(p));
return `/admin/comments?${params.toString()}`;
}
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)" }}>
{total} активных комментариев
</p>
</div>
<Suspense>
<CommentsTable comments={comments} search={search} />
</Suspense>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center gap-1 mt-4">
{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>
);
}
+103 -57
View File
@@ -1,32 +1,81 @@
import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
import { UserPlus } from "lucide-react";
import { UsersTable } from "@/components/admin/users-table";
import { Suspense } from "react";
import { UsersSearch } from "@/components/admin/users-search";
const roleLabel: Record<string, string> = {
admin: "Администратор",
curator: "Куратор",
student: "Ученик",
};
const PAGE_SIZE = 20;
const roleVariant: Record<string, "default" | "secondary" | "outline"> = {
admin: "default",
curator: "secondary",
student: "outline",
};
interface Props {
searchParams: Promise<{ search?: string; role?: string; page?: string }>;
}
export default async function UsersPage() {
const users = await prisma.user.findMany({
orderBy: { createdAt: "desc" },
include: { _count: { select: { enrollments: true } } },
});
export default async function UsersPage({ searchParams }: Props) {
const { search = "", role = "", page = "1" } = await searchParams;
const currentPage = Math.max(1, parseInt(page) || 1);
const skip = (currentPage - 1) * PAGE_SIZE;
const where = {
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ email: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
...(role ? { role } : {}),
};
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: PAGE_SIZE,
include: {
_count: { select: { enrollments: true } },
enrollments: {
include: { course: { select: { title: true } } },
orderBy: { enrolledAt: "desc" },
},
},
}),
prisma.user.count({ where }),
]);
const totalPages = Math.ceil(total / PAGE_SIZE);
const tableUsers = users.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
role: u.role,
emailVerified: u.emailVerified,
createdAt: u.createdAt,
enrollmentCount: u._count.enrollments,
enrollments: u.enrollments.map((e) => ({
courseId: e.courseId,
courseTitle: e.course.title,
expiresAt: e.expiresAt,
})),
}));
function pageUrl(p: number) {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (role) params.set("role", role);
params.set("page", String(p));
return `/admin/users?${params.toString()}`;
}
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-slate-800">Пользователи</h1>
<p className="text-slate-500 text-sm mt-0.5">{users.length} пользователей</p>
<p className="text-slate-500 text-sm mt-0.5">{total} пользователей</p>
</div>
<Link
href="/admin/users/new"
@@ -37,45 +86,42 @@ export default async function UsersPage() {
</Link>
</div>
<div className="bg-white border border-slate-200 rounded-2xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-slate-100 bg-slate-50">
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">Пользователь</th>
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">Роль</th>
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">Курсов</th>
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">Email подтверждён</th>
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">Зарегистрирован</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b last:border-0" style={{ borderColor: "var(--border)" }}>
<td className="px-5 py-3">
<Link href={`/admin/users/${user.id}`} className="font-medium hover:underline" style={{ color: "var(--foreground)" }}>{user.name}</Link>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{user.email}</p>
</td>
<td className="px-5 py-3">
<Badge variant={roleVariant[user.role] ?? "outline"}>
{roleLabel[user.role] ?? user.role}
</Badge>
</td>
<td className="px-5 py-3 text-sm text-slate-600">
{user._count.enrollments}
</td>
<td className="px-5 py-3">
<span className={`text-xs font-medium ${user.emailVerified ? "text-green-600" : "text-slate-400"}`}>
{user.emailVerified ? "Да" : "Нет"}
</span>
</td>
<td className="px-5 py-3 text-sm text-slate-400">
{new Date(user.createdAt).toLocaleDateString("ru-RU")}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Filters */}
<Suspense>
<UsersSearch initialSearch={search} initialRole={role} />
</Suspense>
<UsersTable users={tableUsers} />
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center gap-1 mt-4">
{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>
);
}
+174 -95
View File
@@ -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>
);
}