Files
lms-sb/src/components/admin/quick-enroll-modal.tsx
T
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

253 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)}
</>
);
}