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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+101
-55
@@ -1,32 +1,81 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { UserPlus } from "lucide-react";
|
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> = {
|
const PAGE_SIZE = 20;
|
||||||
admin: "Администратор",
|
|
||||||
curator: "Куратор",
|
interface Props {
|
||||||
student: "Ученик",
|
searchParams: Promise<{ search?: string; role?: string; page?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 roleVariant: Record<string, "default" | "secondary" | "outline"> = {
|
const [users, total] = await Promise.all([
|
||||||
admin: "default",
|
prisma.user.findMany({
|
||||||
curator: "secondary",
|
where,
|
||||||
student: "outline",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function UsersPage() {
|
|
||||||
const users = await prisma.user.findMany({
|
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: { _count: { select: { enrollments: true } } },
|
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 (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-5 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-slate-800">Пользователи</h1>
|
<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>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/users/new"
|
href="/admin/users/new"
|
||||||
@@ -37,45 +86,42 @@ export default async function UsersPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-slate-200 rounded-2xl overflow-hidden">
|
{/* Filters */}
|
||||||
<table className="w-full">
|
<Suspense>
|
||||||
<thead>
|
<UsersSearch initialSearch={search} initialRole={role} />
|
||||||
<tr className="border-b border-slate-100 bg-slate-50">
|
</Suspense>
|
||||||
<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>
|
<UsersTable users={tableUsers} />
|
||||||
<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>
|
{/* Pagination */}
|
||||||
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">Зарегистрирован</th>
|
{totalPages > 1 && (
|
||||||
</tr>
|
<div className="flex items-center gap-1 mt-4">
|
||||||
</thead>
|
{currentPage > 1 && (
|
||||||
<tbody>
|
<Link href={pageUrl(currentPage - 1)} className="btn-aubade px-3 py-1 text-xs">←</Link>
|
||||||
{users.map((user) => (
|
)}
|
||||||
<tr key={user.id} className="border-b last:border-0" style={{ borderColor: "var(--border)" }}>
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
<td className="px-5 py-3">
|
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||||||
<Link href={`/admin/users/${user.id}`} className="font-medium hover:underline" style={{ color: "var(--foreground)" }}>{user.name}</Link>
|
.reduce<(number | "…")[]>((acc, p, i, arr) => {
|
||||||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{user.email}</p>
|
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
|
||||||
</td>
|
acc.push(p);
|
||||||
<td className="px-5 py-3">
|
return acc;
|
||||||
<Badge variant={roleVariant[user.role] ?? "outline"}>
|
}, [])
|
||||||
{roleLabel[user.role] ?? user.role}
|
.map((p, i) =>
|
||||||
</Badge>
|
p === "…" ? (
|
||||||
</td>
|
<span key={`e${i}`} className="px-2 text-xs" style={{ color: "var(--muted-foreground)" }}>…</span>
|
||||||
<td className="px-5 py-3 text-sm text-slate-600">
|
) : (
|
||||||
{user._count.enrollments}
|
<Link key={p} href={pageUrl(p as number)} className="px-3 py-1 text-xs"
|
||||||
</td>
|
style={{ border: "2px solid var(--border)", background: p === currentPage ? "var(--foreground)" : "transparent", color: p === currentPage ? "var(--background)" : "var(--foreground)" }}>
|
||||||
<td className="px-5 py-3">
|
{p}
|
||||||
<span className={`text-xs font-medium ${user.emailVerified ? "text-green-600" : "text-slate-400"}`}>
|
</Link>
|
||||||
{user.emailVerified ? "Да" : "Нет"}
|
)
|
||||||
</span>
|
)}
|
||||||
</td>
|
{currentPage < totalPages && (
|
||||||
<td className="px-5 py-3 text-sm text-slate-400">
|
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs">→</Link>
|
||||||
{new Date(user.createdAt).toLocaleDateString("ru-RU")}
|
)}
|
||||||
</td>
|
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>стр. {currentPage} из {totalPages}</span>
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,53 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { HomeworkFilters } from "@/components/admin/homework-filters";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
export default async function HomeworkListPage() {
|
const PAGE_SIZE = 20;
|
||||||
const submissions = await prisma.homeworkSubmission.findMany({
|
|
||||||
|
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" },
|
orderBy: { submittedAt: "desc" },
|
||||||
|
skip,
|
||||||
|
take: PAGE_SIZE,
|
||||||
include: {
|
include: {
|
||||||
user: { select: { name: true, email: true } },
|
user: { select: { name: true, email: true } },
|
||||||
feedbacks: { select: { id: true } },
|
feedbacks: { select: { id: true } },
|
||||||
@@ -12,87 +56,77 @@ export default async function HomeworkListPage() {
|
|||||||
lesson: {
|
lesson: {
|
||||||
select: {
|
select: {
|
||||||
title: true,
|
title: true,
|
||||||
module: { select: { title: true, course: { 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 totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
const reviewed = submissions.filter((s) => s.feedbacks.length > 0);
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="p-8 max-w-3xl">
|
<div className="p-8 max-w-3xl">
|
||||||
<h1 className="text-2xl font-bold mb-1">Домашние задания</h1>
|
<div className="mb-6">
|
||||||
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
|
<h1
|
||||||
{pending.length} ожидают проверки · {reviewed.length} проверено
|
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>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{pending.length > 0 && (
|
<Suspense>
|
||||||
<div className="mb-8">
|
<HomeworkFilters courses={courses} />
|
||||||
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
</Suspense>
|
||||||
Ожидают проверки
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{pending.map((s) => (
|
|
||||||
<SubmissionRow key={s.id} submission={s} pending />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{reviewed.length > 0 && (
|
{submissions.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 && (
|
|
||||||
<div className="card-aubade p-10 text-center">
|
<div className="card-aubade p-10 text-center">
|
||||||
<p className="text-3xl mb-2">📭</p>
|
<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>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<div className="space-y-1.5">
|
||||||
);
|
{submissions.map((s) => {
|
||||||
}
|
const isPending = s.feedbacks.length === 0;
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/curator/homework/${submission.id}`}
|
key={s.id}
|
||||||
className="flex items-center gap-4 px-4 py-3 text-sm transition-colors"
|
href={`/curator/homework/${s.id}`}
|
||||||
style={{ border: `2px solid ${pending ? "var(--foreground)" : "var(--border)"}`, display: "flex" }}
|
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">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium truncate">{submission.user.name}</p>
|
<p className="font-medium truncate">{s.user.name}</p>
|
||||||
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>
|
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>
|
||||||
{submission.homework.lesson.module.course.title} · {submission.homework.lesson.title}
|
{s.homework.lesson.module.course.title} · {s.homework.lesson.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{s.user.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right shrink-0">
|
<div className="text-right shrink-0">
|
||||||
@@ -100,16 +134,61 @@ function SubmissionRow({
|
|||||||
className="text-xs px-2 py-0.5"
|
className="text-xs px-2 py-0.5"
|
||||||
style={{
|
style={{
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
background: pending ? "var(--foreground)" : "transparent",
|
background: isPending ? "var(--foreground)" : "transparent",
|
||||||
color: pending ? "var(--background)" : "var(--muted-foreground)",
|
color: isPending ? "var(--background)" : "var(--muted-foreground)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{pending ? "Новое" : "Проверено"}
|
{isPending ? "Новое" : "Проверено"}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
|
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
{new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
|
{new Date(s.submittedAt).toLocaleDateString("ru-RU")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const links = [
|
|||||||
{ href: "/admin/categories", label: "Категории" },
|
{ href: "/admin/categories", label: "Категории" },
|
||||||
{ href: "/admin/users", label: "Пользователи" },
|
{ href: "/admin/users", label: "Пользователи" },
|
||||||
{ href: "/curator/homework", label: "ДЗ на проверку" },
|
{ href: "/curator/homework", label: "ДЗ на проверку" },
|
||||||
|
{ href: "/admin/comments", label: "Комментарии" },
|
||||||
{ href: "/admin/import-export", label: "Импорт / Экспорт" },
|
{ href: "/admin/import-export", label: "Импорт / Экспорт" },
|
||||||
{ href: "/admin/settings", label: "Настройки" },
|
{ href: "/admin/settings", label: "Настройки" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
<input
|
||||||
|
defaultValue={search}
|
||||||
|
placeholder="Поиск по автору или тексту"
|
||||||
|
style={{ ...inputStyle, paddingLeft: "2rem", width: 260 }}
|
||||||
|
onFocus={(e) => (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(); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{search && (
|
||||||
|
<button type="button" onClick={() => startTransition(() => router.push(pathname))}
|
||||||
|
className="text-xs px-3" style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<div className="card-aubade p-10 text-center">
|
||||||
|
<p className="font-bold">Комментариев нет</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ border: "2px solid var(--border)" }}>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "2px solid var(--border)", background: "var(--background)" }}>
|
||||||
|
{["Автор", "Урок", "Комментарий", "Дата", ""].map((h) => (
|
||||||
|
<th key={h} className="text-left px-4 py-2.5 text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{comments.map((c) => {
|
||||||
|
const lessonUrl = `/courses/${c.lesson.module.course.slug}/lessons/${c.lesson.id}`;
|
||||||
|
return (
|
||||||
|
<tr key={c.id} style={{ borderBottom: "1px solid var(--border)", opacity: pending ? 0.6 : 1 }}>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap">
|
||||||
|
<Link href={`/admin/users/${c.user.id}`} className="font-medium hover:underline text-sm">
|
||||||
|
{c.user.name}
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs font-mono" style={{ color: "var(--muted-foreground)" }}>{c.user.email}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={lessonUrl} className="text-xs hover:underline" target="_blank">
|
||||||
|
{c.lesson.title}
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{c.lesson.module.course.title}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 max-w-xs">
|
||||||
|
<p className="text-xs line-clamp-2" style={{ color: "var(--foreground)" }}>{c.text}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{new Date(c.createdAt).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(c.id)}
|
||||||
|
title="Удалить"
|
||||||
|
className="p-1.5 transition-opacity hover:opacity-60"
|
||||||
|
style={{ color: "oklch(0.577 0.245 27.325)" }}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-5">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
<input
|
||||||
|
defaultValue={search}
|
||||||
|
placeholder="Имя или email ученика"
|
||||||
|
style={{ ...inputStyle, paddingLeft: "2rem", width: 220 }}
|
||||||
|
onFocus={(e) => (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();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => update("status", e.target.value)}
|
||||||
|
style={{ ...inputStyle, appearance: "none", paddingRight: "1.5rem", cursor: "pointer" }}
|
||||||
|
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||||
|
>
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
<option value="pending">Ожидают проверки</option>
|
||||||
|
<option value="reviewed">Проверено</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Course */}
|
||||||
|
<select
|
||||||
|
value={courseId}
|
||||||
|
onChange={(e) => update("courseId", e.target.value)}
|
||||||
|
style={{ ...inputStyle, appearance: "none", paddingRight: "1.5rem", cursor: "pointer", maxWidth: 220 }}
|
||||||
|
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||||
|
>
|
||||||
|
<option value="">Все курсы</option>
|
||||||
|
{courses.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Reset */}
|
||||||
|
{(search || status || courseId) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
startTransition(() => router.push(pathname));
|
||||||
|
}}
|
||||||
|
className="text-xs px-3"
|
||||||
|
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
<input
|
||||||
|
defaultValue={initialSearch}
|
||||||
|
placeholder="Поиск по имени или email"
|
||||||
|
style={{ ...inputStyle, paddingLeft: "2rem", width: 240 }}
|
||||||
|
onFocus={(e) => (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(); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
defaultValue={initialRole}
|
||||||
|
onChange={(e) => update(initialSearch, e.target.value)}
|
||||||
|
style={{ ...inputStyle, appearance: "none", cursor: "pointer" }}
|
||||||
|
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||||
|
>
|
||||||
|
<option value="">Все роли</option>
|
||||||
|
<option value="student">Ученики</option>
|
||||||
|
<option value="curator">Кураторы</option>
|
||||||
|
<option value="admin">Администраторы</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{(initialSearch || initialRole) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startTransition(() => router.push(pathname))}
|
||||||
|
className="text-xs px-3"
|
||||||
|
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
admin: "Администратор",
|
||||||
|
curator: "Куратор",
|
||||||
|
student: "Ученик",
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleVariant: Record<string, "default" | "secondary" | "outline"> = {
|
||||||
|
admin: "default",
|
||||||
|
curator: "secondary",
|
||||||
|
student: "outline",
|
||||||
|
};
|
||||||
|
|
||||||
|
function UserPopup({ user }: { user: UserRow }) {
|
||||||
|
const now = new Date();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute z-50 right-0 top-full mt-1 w-72 p-4 space-y-3 text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--background)",
|
||||||
|
border: "2px solid var(--foreground)",
|
||||||
|
boxShadow: "4px 4px 0 0 var(--foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Contact */}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Контакты</p>
|
||||||
|
<p className="font-mono text-xs">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Courses */}
|
||||||
|
{user.enrollments.length > 0 ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Курсы ({user.enrollments.length})
|
||||||
|
</p>
|
||||||
|
{user.enrollments.map((e) => {
|
||||||
|
const expired = e.expiresAt && new Date(e.expiresAt) < now;
|
||||||
|
return (
|
||||||
|
<div key={e.courseId} className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-xs flex-1 truncate">{e.courseTitle}</p>
|
||||||
|
<span
|
||||||
|
className="text-xs shrink-0"
|
||||||
|
style={{ color: expired ? "oklch(0.577 0.245 27.325)" : "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
{e.expiresAt
|
||||||
|
? expired
|
||||||
|
? "просрочен"
|
||||||
|
: `до ${new Date(e.expiresAt).toLocaleDateString("ru-RU")}`
|
||||||
|
: "бессрочно"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>Курсов нет</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/admin/users/${user.id}`}
|
||||||
|
className="block text-xs underline"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Открыть профиль →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersTable({ users }: { users: UserRow[] }) {
|
||||||
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-slate-200 rounded-2xl overflow-visible">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-100 bg-slate-50">
|
||||||
|
{["Пользователь", "Роль", "Курсов", "Email подтверждён", "Зарегистрирован", ""].map((h) => (
|
||||||
|
<th key={h} className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">{h}</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.enrollmentCount}</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>
|
||||||
|
{/* Hover popup trigger */}
|
||||||
|
<td className="px-3 py-3 relative">
|
||||||
|
<div
|
||||||
|
className="relative inline-block"
|
||||||
|
onMouseEnter={() => setHoveredId(user.id)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs px-2 py-1"
|
||||||
|
style={{ border: "1px solid var(--border)", color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
···
|
||||||
|
</button>
|
||||||
|
{hoveredId === user.id && <UserPopup user={user} />}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user