Compare commits

...

4 Commits

Author SHA1 Message Date
admins 751c012f3d Add /admin/comments page with delete and pagination
- Add admin auth guard (redirect to /dashboard if not admin)
- Add delete-action.ts with deleteComment(commentId) soft-delete action
- Fix pagination label to show "Страница X из Y · Всего: N"
- Existing actions.ts, comments-table.tsx, and admin-nav entry preserved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:31:38 +05:00
admins 7084806aac Add search, filters, pagination to homework list
- Replace client HomeworkFilters component with server-side <form method="GET">
- Switch search param from `search` to `q`, add lesson title to search scope
- Change status filter from DB status field to feedback-count logic (pending=no feedbacks, reviewed=has feedbacks)
- Update pagination label to "Страница X из Y · Всего: N"
- Preserve all existing submission links and layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:29:47 +05:00
admins b2fa98051f Add quick enroll button to admin users table
Adds a "+ Доступ" button to each user row in the admin users table.
Clicking it opens a centered modal with a course dropdown, optional
expiry date, and a Server Action that upserts CourseEnrollment, logs
to AccessLog, and sends sendCourseAccessEmail on new enrollments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:27:57 +05:00
admins 4f5b5c535a Add search, filters, pagination to admin users table
- Add emailVerified filter (true/false/any) to UsersSearch component
- Wire emailVerified param through page searchParams, where clause, and pageUrl helper
- Preserve emailVerified in pagination links alongside existing search/role/balance params
- Update pagination label to "Страница X из Y · Всего: N"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:25:53 +05:00
8 changed files with 464 additions and 41 deletions
+19
View File
@@ -0,0 +1,19 @@
"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 deleteComment(commentId: string): Promise<{ ok: boolean }> {
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");
return { ok: true };
}
+7 -1
View File
@@ -2,6 +2,9 @@ import { prisma } from "@/lib/prisma";
import Link from "next/link";
import { Suspense } from "react";
import { CommentsTable } from "@/components/admin/comments-table";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export const metadata = { title: "Комментарии" };
@@ -12,6 +15,9 @@ interface Props {
}
export default async function AdminCommentsPage({ searchParams }: Props) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") redirect("/dashboard");
const { page = "1", search = "" } = await searchParams;
const currentPage = Math.max(1, parseInt(page) || 1);
const skip = (currentPage - 1) * PAGE_SIZE;
@@ -106,7 +112,7 @@ export default async function AdminCommentsPage({ searchParams }: Props) {
{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>
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>Страница {currentPage} из {totalPages} · Всего: {total}</span>
</div>
)}
</div>
+75
View File
@@ -0,0 +1,75 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { sendCourseAccessEmail } from "@/lib/email";
import { revalidatePath } from "next/cache";
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
return session;
}
export async function grantCourseAccess(
userId: string,
courseId: string,
expiresAt: Date | null
): Promise<{ ok: true } | { error: string }> {
try {
const session = await requireAdmin();
const [user, course] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
prisma.course.findUnique({ where: { id: courseId }, select: { title: true } }),
]);
if (!user) return { error: "Пользователь не найден" };
if (!course) return { error: "Курс не найден" };
const existing = await prisma.courseEnrollment.findUnique({
where: { userId_courseId: { userId, courseId } },
});
await prisma.courseEnrollment.upsert({
where: { userId_courseId: { userId, courseId } },
update: { expiresAt },
create: { userId, courseId, expiresAt },
});
await prisma.accessLog.create({
data: {
courseId,
userId,
action: "granted",
method: "quick",
grantedById: session.user.id,
},
});
// Send email only on new enrollment (not on update)
if (!existing) {
await sendCourseAccessEmail(user.email, user.name ?? user.email, course.title).catch(
(e) => console.error("[enroll-action] sendCourseAccessEmail:", e)
);
}
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`);
return { ok: true };
} catch (e) {
console.error("[enroll-action] grantCourseAccess:", e);
return { error: "Произошла ошибка. Попробуйте ещё раз." };
}
}
export async function getPublishedCourses(): Promise<{ id: string; title: string }[]> {
await requireAdmin();
return prisma.course.findMany({
where: { published: true },
select: { id: true, title: true },
orderBy: { title: "asc" },
});
}
+7 -4
View File
@@ -8,11 +8,11 @@ import { UsersSearch } from "@/components/admin/users-search";
const PAGE_SIZE = 20;
interface Props {
searchParams: Promise<{ search?: string; role?: string; page?: string; balance?: string }>;
searchParams: Promise<{ search?: string; role?: string; page?: string; balance?: string; emailVerified?: string }>;
}
export default async function UsersPage({ searchParams }: Props) {
const { search = "", role = "", page = "1", balance = "" } = await searchParams;
const { search = "", role = "", page = "1", balance = "", emailVerified = "" } = await searchParams;
const currentPage = Math.max(1, parseInt(page) || 1);
const skip = (currentPage - 1) * PAGE_SIZE;
@@ -37,6 +37,8 @@ export default async function UsersPage({ searchParams }: Props) {
}
: {}),
...(role ? { role } : {}),
...(emailVerified === "true" ? { emailVerified: true } : {}),
...(emailVerified === "false" ? { emailVerified: false } : {}),
...(balanceUserIds !== null ? { id: { in: balanceUserIds } } : {}),
};
@@ -78,6 +80,7 @@ export default async function UsersPage({ searchParams }: Props) {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (role) params.set("role", role);
if (emailVerified) params.set("emailVerified", emailVerified);
if (balance) params.set("balance", balance);
params.set("page", String(p));
return `/admin/users?${params.toString()}`;
@@ -101,7 +104,7 @@ export default async function UsersPage({ searchParams }: Props) {
{/* Filters */}
<Suspense>
<UsersSearch initialSearch={search} initialRole={role} initialBalance={balance} />
<UsersSearch initialSearch={search} initialRole={role} initialEmailVerified={emailVerified} initialBalance={balance} />
</Suspense>
<UsersTable users={tableUsers} />
@@ -132,7 +135,7 @@ export default async function UsersPage({ searchParams }: Props) {
{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>
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>Страница {currentPage} из {totalPages} · Всего: {total}</span>
</div>
)}
</div>
+80 -29
View File
@@ -1,13 +1,12 @@
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import { HomeworkFilters } from "@/components/admin/homework-filters";
import { Suspense } from "react";
const PAGE_SIZE = 20;
interface Props {
searchParams: Promise<{
search?: string;
q?: string;
status?: string;
courseId?: string;
page?: string;
@@ -15,20 +14,22 @@ interface Props {
}
export default async function HomeworkListPage({ searchParams }: Props) {
const { search = "", status = "", courseId = "", page = "1" } = await searchParams;
const currentPage = Math.max(1, parseInt(page) || 1);
const sp = await searchParams;
const q = sp.q ?? "";
const status = sp.status ?? "";
const courseId = sp.courseId ?? "";
const currentPage = Math.max(1, parseInt(sp.page ?? "1") || 1);
const skip = (currentPage - 1) * PAGE_SIZE;
// Build where clause
const where = {
...(search
...(q
? {
user: {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ email: { contains: search, mode: "insensitive" as const } },
{ user: { name: { contains: q, mode: "insensitive" as const } } },
{ user: { email: { contains: q, mode: "insensitive" as const } } },
{ homework: { lesson: { title: { contains: q, mode: "insensitive" as const } } } },
],
},
}
: {}),
...(courseId
@@ -38,10 +39,8 @@ export default async function HomeworkListPage({ searchParams }: Props) {
},
}
: {}),
...(status === "pending" ? { status: "PENDING" } : {}),
...(status === "reviewing" ? { status: "REVIEWING" } : {}),
...(status === "approved" ? { status: "APPROVED" } : {}),
...(status === "rejected" ? { status: "REJECTED" } : {}),
...(status === "pending" ? { feedbacks: { none: {} } } : {}),
...(status === "reviewed" ? { feedbacks: { some: {} } } : {}),
};
const [submissions, total, courses] = await Promise.all([
@@ -71,17 +70,25 @@ export default async function HomeworkListPage({ searchParams }: Props) {
const totalPages = Math.ceil(total / PAGE_SIZE);
const pendingCount = submissions.filter((s) => s.status === "PENDING").length;
function pageUrl(p: number) {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (q) params.set("q", q);
if (status) params.set("status", status);
if (courseId) params.set("courseId", courseId);
params.set("page", String(p));
return `/curator/homework?${params.toString()}`;
}
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
padding: "0.4rem 0.75rem",
fontSize: "0.8rem",
fontFamily: "inherit",
color: "var(--foreground)",
};
return (
<div className="p-8 max-w-3xl">
<div className="mb-6">
@@ -92,12 +99,59 @@ export default async function HomeworkListPage({ searchParams }: Props) {
Домашние задания
</h1>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
{total} {total === 1 ? "работа" : "работ"} · {pendingCount} ожидают проверки
{total} {total === 1 ? "работа" : "работ"}
</p>
</div>
{/* Filters */}
<Suspense>
<HomeworkFilters courses={courses} />
<form method="GET" className="flex flex-wrap gap-2 mb-5">
<input
name="q"
defaultValue={q}
placeholder="Поиск по ученику или уроку"
style={{ ...inputStyle, width: 260 }}
/>
<select
name="courseId"
defaultValue={courseId}
style={{ ...inputStyle, appearance: "none", paddingRight: "1.5rem", cursor: "pointer", maxWidth: 220 }}
>
<option value="">Все курсы</option>
{courses.map((c) => (
<option key={c.id} value={c.id}>{c.title}</option>
))}
</select>
<select
name="status"
defaultValue={status}
style={{ ...inputStyle, appearance: "none", paddingRight: "1.5rem", cursor: "pointer" }}
>
<option value="">Все</option>
<option value="pending">Без ответа</option>
<option value="reviewed">С отзывом</option>
</select>
<button
type="submit"
className="px-3 py-1 text-xs font-medium"
style={{ border: "2px solid var(--foreground)", background: "var(--foreground)", color: "var(--background)", cursor: "pointer" }}
>
Найти
</button>
{(q || status || courseId) && (
<Link
href="/curator/homework"
className="px-3 py-1 text-xs"
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
Сбросить
</Link>
)}
</form>
</Suspense>
{submissions.length === 0 ? (
@@ -111,19 +165,16 @@ export default async function HomeworkListPage({ searchParams }: Props) {
) : (
<div className="space-y-1.5">
{submissions.map((s) => {
const statusMap: Record<string, { label: string; bg: string; color: string; border: string }> = {
PENDING: { label: "Новое", bg: "var(--foreground)", color: "var(--background)", border: "var(--foreground)" },
REVIEWING: { label: "На рассмотрении", bg: "oklch(0.9 0.08 80)", color: "oklch(0.4 0.1 80)", border: "oklch(0.75 0.1 80)" },
APPROVED: { label: "Одобрено", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)", border: "oklch(0.7 0.15 145)" },
REJECTED: { label: "Отклонено", bg: "oklch(0.9 0.06 27)", color: "oklch(0.45 0.2 27)", border: "oklch(0.75 0.1 27)" },
};
const st = statusMap[s.status] ?? statusMap.PENDING;
const hasReview = s.feedbacks.length > 0;
const reviewBadge = hasReview
? { label: "С отзывом", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)" }
: { label: "Без ответа", bg: "var(--foreground)", color: "var(--background)" };
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 ${st.border}`, display: "flex" }}
style={{ border: "2px solid var(--border)", display: "flex" }}
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{s.user.name}</p>
@@ -137,9 +188,9 @@ export default async function HomeworkListPage({ searchParams }: Props) {
<div className="text-right shrink-0">
<span
className="text-xs px-2 py-0.5 font-medium"
style={{ background: st.bg, color: st.color }}
style={{ background: reviewBadge.bg, color: reviewBadge.color }}
>
{st.label}
{reviewBadge.label}
</span>
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
{new Date(s.submittedAt).toLocaleDateString("ru-RU")}
@@ -186,7 +237,7 @@ export default async function HomeworkListPage({ searchParams }: Props) {
<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}
Страница {currentPage} из {totalPages} · Всего: {total}
</span>
</div>
)}
+252
View File
@@ -0,0 +1,252 @@
"use client";
import { useState, useTransition, useEffect, useRef } from "react";
import { grantCourseAccess, getPublishedCourses } from "@/app/admin/users/enroll-action";
interface Course {
id: string;
title: string;
}
interface Props {
userId: string;
userName: string;
}
export function QuickEnrollButton({ userId, userName }: Props) {
const [open, setOpen] = useState(false);
const [courses, setCourses] = useState<Course[]>([]);
const [loadingCourses, setLoadingCourses] = useState(false);
const [courseId, setCourseId] = useState("");
const [expiresAt, setExpiresAt] = useState("");
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");
const [errorMsg, setErrorMsg] = useState("");
const [pending, startTransition] = useTransition();
const dialogRef = useRef<HTMLDivElement>(null);
// Load courses when modal opens
useEffect(() => {
if (!open) return;
setLoadingCourses(true);
getPublishedCourses()
.then((data) => {
setCourses(data);
if (data.length > 0) setCourseId(data[0].id);
})
.finally(() => setLoadingCourses(false));
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") handleClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open]);
function handleOpen() {
setStatus("idle");
setErrorMsg("");
setExpiresAt("");
setCourseId("");
setOpen(true);
}
function handleClose() {
if (pending) return;
setOpen(false);
}
function handleSubmit() {
if (!courseId) return;
const expiry = expiresAt ? new Date(expiresAt) : null;
startTransition(async () => {
const result = await grantCourseAccess(userId, courseId, expiry);
if ("error" in result) {
setStatus("error");
setErrorMsg(result.error);
} else {
setStatus("success");
setTimeout(() => setOpen(false), 1200);
}
});
}
return (
<>
{/* Trigger button */}
<button
type="button"
onClick={handleOpen}
className="text-xs px-2 py-1 transition-colors"
style={{
border: "1px solid var(--border)",
color: "var(--foreground)",
background: "transparent",
fontWeight: 600,
whiteSpace: "nowrap",
}}
title={`Выдать доступ к курсу — ${userName}`}
>
+ Доступ
</button>
{/* Modal overlay */}
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: "rgba(50,50,50,0.45)" }}
onClick={(e) => {
if (e.target === e.currentTarget) handleClose();
}}
>
<div
ref={dialogRef}
className="w-full max-w-sm p-6 space-y-4"
style={{
background: "var(--background)",
border: "2px solid var(--foreground)",
boxShadow: "6px 6px 0 0 var(--foreground)",
}}
>
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div>
<p
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Выдать доступ
</p>
<p className="text-sm font-semibold mt-0.5" style={{ color: "var(--foreground)" }}>
{userName}
</p>
</div>
<button
type="button"
onClick={handleClose}
disabled={pending}
className="text-xs leading-none"
style={{ color: "var(--muted-foreground)", fontSize: "18px", lineHeight: 1 }}
>
×
</button>
</div>
{/* Course select */}
<div>
<label
className="block text-xs font-bold uppercase tracking-widest mb-1.5"
style={{ color: "var(--muted-foreground)" }}
>
Курс
</label>
{loadingCourses ? (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Загрузка
</p>
) : courses.length === 0 ? (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Нет опубликованных курсов
</p>
) : (
<select
value={courseId}
onChange={(e) => setCourseId(e.target.value)}
disabled={pending}
style={{
width: "100%",
border: "2px solid var(--border)",
background: "var(--background)",
color: "var(--foreground)",
padding: "0.4rem 0.5rem",
fontSize: "13px",
fontFamily: "inherit",
outline: "none",
}}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
>
{courses.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</select>
)}
</div>
{/* Expiry date (optional) */}
<div>
<label
className="block text-xs font-bold uppercase tracking-widest mb-1.5"
style={{ color: "var(--muted-foreground)" }}
>
Срок доступа (необязательно)
</label>
<input
type="date"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
disabled={pending}
min={new Date().toISOString().slice(0, 10)}
style={{
width: "100%",
border: "2px solid var(--border)",
background: "var(--background)",
color: expiresAt ? "var(--foreground)" : "var(--muted-foreground)",
padding: "0.4rem 0.5rem",
fontSize: "13px",
fontFamily: "inherit",
outline: "none",
}}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
Оставьте пустым для бессрочного доступа
</p>
</div>
{/* Error message */}
{status === "error" && (
<p className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
{errorMsg}
</p>
)}
{/* Success message */}
{status === "success" && (
<p className="text-xs font-semibold" style={{ color: "#3A6A3A" }}>
Доступ выдан
</p>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
<button
type="button"
onClick={handleSubmit}
disabled={pending || loadingCourses || !courseId || status === "success"}
className="btn-aubade btn-aubade-accent text-xs px-4 py-2"
>
{pending ? "Выдаю…" : "Выдать"}
</button>
<button
type="button"
onClick={handleClose}
disabled={pending}
className="btn-aubade text-xs px-4 py-2"
style={{ color: "var(--muted-foreground)" }}
>
Отмена
</button>
</div>
</div>
</div>
)}
</>
);
}
+20 -5
View File
@@ -16,20 +16,23 @@ const inputStyle: React.CSSProperties = {
export function UsersSearch({
initialSearch,
initialRole,
initialEmailVerified,
initialBalance,
}: {
initialSearch: string;
initialRole: string;
initialEmailVerified: string;
initialBalance: string;
}) {
const router = useRouter();
const pathname = usePathname();
const [, startTransition] = useTransition();
function update(search: string, role: string, balance: string) {
function update(search: string, role: string, emailVerified: string, balance: string) {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (role) params.set("role", role);
if (emailVerified) params.set("emailVerified", emailVerified);
if (balance) params.set("balance", balance);
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
@@ -45,7 +48,7 @@ export function UsersSearch({
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
update(e.currentTarget.value.trim(), initialRole, initialBalance);
update(e.currentTarget.value.trim(), initialRole, initialEmailVerified, initialBalance);
}}
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }}
/>
@@ -53,7 +56,7 @@ export function UsersSearch({
<select
defaultValue={initialRole}
onChange={(e) => update(initialSearch, e.target.value, initialBalance)}
onChange={(e) => update(initialSearch, e.target.value, initialEmailVerified, initialBalance)}
style={{ ...inputStyle, appearance: "none", cursor: "pointer" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
@@ -64,9 +67,21 @@ export function UsersSearch({
<option value="admin">Администраторы</option>
</select>
<select
defaultValue={initialEmailVerified}
onChange={(e) => update(initialSearch, initialRole, e.target.value, initialBalance)}
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="true">Email подтверждён</option>
<option value="false">Не подтверждён</option>
</select>
<button
type="button"
onClick={() => update(initialSearch, initialRole, initialBalance === "nonzero" ? "" : "nonzero")}
onClick={() => update(initialSearch, initialRole, initialEmailVerified, initialBalance === "nonzero" ? "" : "nonzero")}
className="text-xs px-3"
style={{
border: "2px solid var(--border)",
@@ -78,7 +93,7 @@ export function UsersSearch({
С балансом
</button>
{(initialSearch || initialRole || initialBalance) && (
{(initialSearch || initialRole || initialEmailVerified || initialBalance) && (
<button
type="button"
onClick={() => startTransition(() => router.push(pathname))}
+2
View File
@@ -3,6 +3,7 @@
import { useState } from "react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { QuickEnrollButton } from "@/components/admin/quick-enroll-modal";
type Enrollment = {
courseId: string;
@@ -175,6 +176,7 @@ export function UsersTable({ users }: { users: UserRow[] }) {
<td className="px-3 py-3 relative">
<div className="flex items-center gap-2">
{user.role !== "admin" && <ImpersonateButton userId={user.id} />}
<QuickEnrollButton userId={user.id} userName={user.name ?? user.email} />
<div
className="relative inline-block"
onMouseEnter={() => setHoveredId(user.id)}