-
+
Пользователи
-
{users.length} пользователей
+
{total} пользователей
-
-
-
-
- | Пользователь |
- Роль |
- Курсов |
- Email подтверждён |
- Зарегистрирован |
-
-
-
- {users.map((user) => (
-
- |
- {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) => (
+ | {h} |
+ ))}
+
+
+
+ {comments.map((c) => {
+ const lessonUrl = `/courses/${c.lesson.module.course.slug}/lessons/${c.lesson.id}`;
+ return (
+
+ |
+
+ {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) => (
+ | {h} |
+ ))}
+
+
+
+ {users.map((user) => (
+
+ |
+
+ {user.name}
+
+ {user.email}
+ |
+
+
+ {roleLabel[user.role] ?? user.role}
+
+ |
+ {user.enrollmentCount} |
+
+
+ {user.emailVerified ? "Да" : "Нет"}
+
+ |
+
+ {new Date(user.createdAt).toLocaleDateString("ru-RU")}
+ |
+ {/* Hover popup trigger */}
+
+ setHoveredId(user.id)}
+ onMouseLeave={() => setHoveredId(null)}
+ >
+
+ {hoveredId === user.id && }
+
+ |
+
+ ))}
+
+
+
+ );
+}