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:
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user