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>
This commit is contained in:
2026-05-19 14:27:57 +05:00
parent 4f5b5c535a
commit b2fa98051f
3 changed files with 329 additions and 0 deletions
+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>
)}
</>
);
}