diff --git a/src/app/admin/comments/actions.ts b/src/app/admin/comments/actions.ts new file mode 100644 index 0000000..7631244 --- /dev/null +++ b/src/app/admin/comments/actions.ts @@ -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"); +} diff --git a/src/app/admin/comments/page.tsx b/src/app/admin/comments/page.tsx new file mode 100644 index 0000000..b09a00c --- /dev/null +++ b/src/app/admin/comments/page.tsx @@ -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 ( +
+
+

+ Комментарии +

+

+ {total} активных комментариев +

+
+ + + + + + {/* Pagination */} + {totalPages > 1 && ( +
+ {currentPage > 1 && ( + ← + )} + {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 === "…" ? ( + + ) : ( + + {p} + + ) + )} + {currentPage < totalPages && ( + → + )} + стр. {currentPage} из {totalPages} +
+ )} +
+ ); +} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index e1cf675..9a6756f 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -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 = { - admin: "Администратор", - curator: "Куратор", - student: "Ученик", -}; +const PAGE_SIZE = 20; -const roleVariant: Record = { - 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 (
-
+

Пользователи

-

{users.length} пользователей

+

{total} пользователей

-
- - - - - - - - - - - - {users.map((user) => ( - - - - - - - - ))} - -
ПользовательРольКурсовEmail подтверждёнЗарегистрирован
- {user.name} -

{user.email}

-
- - {roleLabel[user.role] ?? user.role} - - - {user._count.enrollments} - - - {user.emailVerified ? "Да" : "Нет"} - - - {new Date(user.createdAt).toLocaleDateString("ru-RU")} -
-
+ {/* Filters */} + + + + + + + {/* Pagination */} + {totalPages > 1 && ( +
+ {currentPage > 1 && ( + ← + )} + {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 === "…" ? ( + + ) : ( + + {p} + + ) + )} + {currentPage < totalPages && ( + → + )} + стр. {currentPage} из {totalPages} +
+ )}
); } diff --git a/src/app/curator/homework/page.tsx b/src/app/curator/homework/page.tsx index b852d94..140ecf5 100644 --- a/src/app/curator/homework/page.tsx +++ b/src/app/curator/homework/page.tsx @@ -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 (
-

Домашние задания

-

- {pending.length} ожидают проверки · {reviewed.length} проверено -

+
+

+ Домашние задания +

+

+ {total} {total === 1 ? "работа" : "работ"} · {pendingCount} ожидают проверки +

+
- {pending.length > 0 && ( -
-

- Ожидают проверки -

-
- {pending.map((s) => ( - - ))} -
-
- )} + + + - {reviewed.length > 0 && ( -
-

- Проверено -

-
- {reviewed.map((s) => ( - - ))} -
-
- )} - - {submissions.length === 0 && ( + {submissions.length === 0 ? (

📭

-

Работ пока нет

+

Ничего не найдено

+

+ Попробуйте изменить фильтры +

+
+ ) : ( +
+ {submissions.map((s) => { + const isPending = s.feedbacks.length === 0; + return ( + +
+

{s.user.name}

+

+ {s.homework.lesson.module.course.title} · {s.homework.lesson.title} +

+

+ {s.user.email} +

+
+
+ + {isPending ? "Новое" : "Проверено"} + +

+ {new Date(s.submittedAt).toLocaleDateString("ru-RU")} +

+
+ + ); + })} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ {currentPage > 1 && ( + ← + )} + {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 === "…" ? ( + + ) : ( + + {p} + + ) + )} + {currentPage < totalPages && ( + → + )} + + стр. {currentPage} из {totalPages} +
)}
); } - -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 ( - -
-

{submission.user.name}

-

- {submission.homework.lesson.module.course.title} · {submission.homework.lesson.title} -

-
-
- - {pending ? "Новое" : "Проверено"} - -

- {new Date(submission.submittedAt).toLocaleDateString("ru-RU")} -

-
- - ); -} diff --git a/src/components/admin/admin-nav.tsx b/src/components/admin/admin-nav.tsx index bd84589..f16c1b6 100644 --- a/src/components/admin/admin-nav.tsx +++ b/src/components/admin/admin-nav.tsx @@ -9,6 +9,7 @@ const links = [ { href: "/admin/categories", label: "Категории" }, { href: "/admin/users", label: "Пользователи" }, { href: "/curator/homework", label: "ДЗ на проверку" }, + { href: "/admin/comments", label: "Комментарии" }, { href: "/admin/import-export", label: "Импорт / Экспорт" }, { href: "/admin/settings", label: "Настройки" }, ]; diff --git a/src/components/admin/comments-table.tsx b/src/components/admin/comments-table.tsx new file mode 100644 index 0000000..7bcd7d0 --- /dev/null +++ b/src/components/admin/comments-table.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useRouter, usePathname } from "next/navigation"; +import { useTransition } from "react"; +import { Trash2, Search } from "lucide-react"; +import Link from "next/link"; +import { adminDeleteComment } from "@/app/admin/comments/actions"; + +type Comment = { + id: string; + text: string; + createdAt: Date; + user: { id: string; name: string; email: string }; + lesson: { + id: string; + title: string; + module: { course: { slug: string; title: string } }; + }; +}; + +const inputStyle: React.CSSProperties = { + border: "2px solid var(--border)", + background: "var(--background)", + outline: "none", + padding: "0.4rem 0.75rem", + fontSize: "0.8rem", + fontFamily: "inherit", +}; + +export function CommentsTable({ comments, search }: { comments: Comment[]; search: string }) { + const router = useRouter(); + const pathname = usePathname(); + const [pending, startTransition] = useTransition(); + + function updateSearch(value: string) { + const params = new URLSearchParams(); + if (value) params.set("search", value); + startTransition(() => router.push(`${pathname}?${params.toString()}`)); + } + + function handleDelete(id: string) { + if (!confirm("Удалить комментарий?")) return; + startTransition(async () => { + await adminDeleteComment(id); + }); + } + + return ( +
+ {/* Search */} +
+
+ + (e.currentTarget.style.borderColor = "var(--foreground)")} + onBlur={(e) => { + e.currentTarget.style.borderColor = "var(--border)"; + updateSearch(e.currentTarget.value.trim()); + }} + onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }} + /> +
+ {search && ( + + )} +
+ + {comments.length === 0 ? ( +
+

Комментариев нет

+
+ ) : ( +
+ + + + {["Автор", "Урок", "Комментарий", "Дата", ""].map((h) => ( + + ))} + + + + {comments.map((c) => { + const lessonUrl = `/courses/${c.lesson.module.course.slug}/lessons/${c.lesson.id}`; + return ( + + + + + + + + ); + })} + +
{h}
+ + {c.user.name} + +

{c.user.email}

+
+ + {c.lesson.title} + +

+ {c.lesson.module.course.title} +

+
+

{c.text}

+
+ {new Date(c.createdAt).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })} + + +
+
+ )} +
+ ); +} diff --git a/src/components/admin/homework-filters.tsx b/src/components/admin/homework-filters.tsx new file mode 100644 index 0000000..2619639 --- /dev/null +++ b/src/components/admin/homework-filters.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { useTransition } from "react"; +import { Search } from "lucide-react"; + +const inputStyle: React.CSSProperties = { + border: "2px solid var(--border)", + background: "var(--background)", + outline: "none", + padding: "0.4rem 0.75rem", + fontSize: "0.8rem", + fontFamily: "inherit", +}; + +type Course = { id: string; title: string }; + +export function HomeworkFilters({ courses }: { courses: Course[] }) { + const router = useRouter(); + const pathname = usePathname(); + const sp = useSearchParams(); + const [, startTransition] = useTransition(); + + function update(key: string, value: string) { + const params = new URLSearchParams(sp.toString()); + if (value) params.set(key, value); + else params.delete(key); + params.delete("page"); // reset to page 1 on filter change + startTransition(() => router.push(`${pathname}?${params.toString()}`)); + } + + const status = sp.get("status") ?? ""; + const courseId = sp.get("courseId") ?? ""; + const search = sp.get("search") ?? ""; + + return ( +
+ {/* Search */} +
+ + (e.currentTarget.style.borderColor = "var(--foreground)")} + onBlur={(e) => { + e.currentTarget.style.borderColor = "var(--border)"; + update("search", e.currentTarget.value.trim()); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + }} + /> +
+ + {/* Status */} + + + {/* Course */} + + + {/* Reset */} + {(search || status || courseId) && ( + + )} +
+ ); +} diff --git a/src/components/admin/users-search.tsx b/src/components/admin/users-search.tsx new file mode 100644 index 0000000..9aa71f1 --- /dev/null +++ b/src/components/admin/users-search.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useRouter, usePathname } from "next/navigation"; +import { useTransition } from "react"; +import { Search } from "lucide-react"; + +const inputStyle: React.CSSProperties = { + border: "2px solid var(--border)", + background: "var(--background)", + outline: "none", + padding: "0.4rem 0.75rem", + fontSize: "0.8rem", + fontFamily: "inherit", +}; + +export function UsersSearch({ initialSearch, initialRole }: { initialSearch: string; initialRole: string }) { + const router = useRouter(); + const pathname = usePathname(); + const [, startTransition] = useTransition(); + + function update(search: string, role: string) { + const params = new URLSearchParams(); + if (search) params.set("search", search); + if (role) params.set("role", role); + startTransition(() => router.push(`${pathname}?${params.toString()}`)); + } + + return ( +
+
+ + (e.currentTarget.style.borderColor = "var(--foreground)")} + onBlur={(e) => { + e.currentTarget.style.borderColor = "var(--border)"; + update(e.currentTarget.value.trim(), initialRole); + }} + onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }} + /> +
+ + + + {(initialSearch || initialRole) && ( + + )} +
+ ); +} diff --git a/src/components/admin/users-table.tsx b/src/components/admin/users-table.tsx new file mode 100644 index 0000000..48fd2b3 --- /dev/null +++ b/src/components/admin/users-table.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; + +type Enrollment = { + courseId: string; + courseTitle: string; + expiresAt: Date | null; +}; + +type UserRow = { + id: string; + name: string; + email: string; + role: string; + emailVerified: boolean; + createdAt: Date; + enrollmentCount: number; + enrollments: Enrollment[]; +}; + +const roleLabel: Record = { + admin: "Администратор", + curator: "Куратор", + student: "Ученик", +}; + +const roleVariant: Record = { + admin: "default", + curator: "secondary", + student: "outline", +}; + +function UserPopup({ user }: { user: UserRow }) { + const now = new Date(); + return ( +
+ {/* Contact */} +
+

Контакты

+

{user.email}

+
+ + {/* Courses */} + {user.enrollments.length > 0 ? ( +
+

+ Курсы ({user.enrollments.length}) +

+ {user.enrollments.map((e) => { + const expired = e.expiresAt && new Date(e.expiresAt) < now; + return ( +
+

{e.courseTitle}

+ + {e.expiresAt + ? expired + ? "просрочен" + : `до ${new Date(e.expiresAt).toLocaleDateString("ru-RU")}` + : "бессрочно"} + +
+ ); + })} +
+ ) : ( +

Курсов нет

+ )} + + + Открыть профиль → + +
+ ); +} + +export function UsersTable({ users }: { users: UserRow[] }) { + const [hoveredId, setHoveredId] = useState(null); + + return ( +
+ + + + {["Пользователь", "Роль", "Курсов", "Email подтверждён", "Зарегистрирован", ""].map((h) => ( + + ))} + + + + {users.map((user) => ( + + + + + + + {/* Hover popup trigger */} + + + ))} + +
{h}
+ + {user.name} + +

{user.email}

+
+ + {roleLabel[user.role] ?? user.role} + + {user.enrollmentCount} + + {user.emailVerified ? "Да" : "Нет"} + + + {new Date(user.createdAt).toLocaleDateString("ru-RU")} + +
setHoveredId(user.id)} + onMouseLeave={() => setHoveredId(null)} + > + + {hoveredId === user.id && } +
+
+
+ ); +}