Stage 1.5: categories, enrollment expiry, access log, bulk grant, user page

This commit is contained in:
2026-04-07 11:59:13 +05:00
parent 992763aeb9
commit e9eff5bae5
16 changed files with 790 additions and 93 deletions
+5 -7
View File
@@ -39,17 +39,15 @@
---
## Этап 1.5 — Расширенное управление доступом (из emdesell)
**Цель:** способы выдачи доступа как в emdesell — ключи активации, срок доступа, категории.
## Этап 1.5 — Расширенное управление доступом
**Цель:** гибкое управление доступом: сроки, категории, пакеты, история.
- [ ] **Ключи активации:** генерировать N одноразовых кодов для курса → ученик вводит код и получает доступ
- [ ] **Срок доступа:** поле `expiresAt` в `CourseEnrollment` + автоблокировка по дате
- [ ] **Категории курсов:** таблица `Category`, поле `categoryId` в `Course`, фильтрация в списке
- [ ] **Расширенный энролл:** Admin может дать доступ к нескольким курсам сразу (пакеты)
- [ ] **История доступа:** лог выдачи/отзыва доступа (кто, когда, каким методом)
- [ ] **Страница активации** для ученика: `/activate` — ввод кода → редирект на курс
- [ ] **Расширенный энролл:** на странице ученика — дать доступ сразу к нескольким курсам
- [ ] **История доступа:** лог выдачи/отзыва (кто, когда, метод, примечание)
**Критерий готовности:** генерирую 10 ключей, отдаю ученику, ученик вводит — получает доступ с датой истечения через 3 месяца.
**Критерий готовности:** задаю ученику доступ к 3 курсам с разными сроками, в логе вижу все операции.
---
@@ -0,0 +1,38 @@
-- CreateTable: Category
CREATE TABLE "Category" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
-- Add categoryId to Course
ALTER TABLE "Course" ADD COLUMN "categoryId" TEXT;
ALTER TABLE "Course" ADD CONSTRAINT "Course_categoryId_fkey"
FOREIGN KEY ("categoryId") REFERENCES "Category"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
-- Add expiresAt to CourseEnrollment
ALTER TABLE "CourseEnrollment" ADD COLUMN "expiresAt" TIMESTAMP(3);
-- CreateTable: AccessLog
CREATE TABLE "AccessLog" (
"id" TEXT NOT NULL,
"courseId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"method" TEXT NOT NULL DEFAULT 'manual',
"grantedById" TEXT,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AccessLog_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_courseId_fkey"
FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_grantedById_fkey"
FOREIGN KEY ("grantedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+39 -8
View File
@@ -24,13 +24,15 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
enrollments CourseEnrollment[]
progress LessonProgress[]
submissions HomeworkSubmission[]
comments LessonComment[]
feedbacks HomeworkFeedback[]
sessions Session[]
accounts Account[]
enrollments CourseEnrollment[]
progress LessonProgress[]
submissions HomeworkSubmission[]
comments LessonComment[]
feedbacks HomeworkFeedback[]
accessLogs AccessLog[] @relation("AccessLogUser")
adminAccessLogs AccessLog[] @relation("AccessLogAdmin")
}
model Session {
@@ -77,6 +79,16 @@ model Verification {
// LMS core tables
// ─────────────────────────────────────────────
model Category {
id String @id @default(cuid())
title String
slug String @unique
order Int @default(0)
createdAt DateTime @default(now())
courses Course[]
}
model Course {
id String @id @default(cuid())
slug String @unique
@@ -85,11 +97,14 @@ model Course {
coverImage String?
published Boolean @default(false)
order Int @default(0)
categoryId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
modules Module[]
enrollments CourseEnrollment[]
accessLogs AccessLog[]
}
model Module {
@@ -137,7 +152,8 @@ model LessonFile {
model CourseEnrollment {
userId String
courseId String
enrolledAt DateTime @default(now())
enrolledAt DateTime @default(now())
expiresAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@ -145,6 +161,21 @@ model CourseEnrollment {
@@id([userId, courseId])
}
model AccessLog {
id String @id @default(cuid())
courseId String
userId String
action String // "granted" | "revoked"
method String @default("manual")
grantedById String?
note String?
createdAt DateTime @default(now())
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
user User @relation("AccessLogUser", fields: [userId], references: [id], onDelete: Cascade)
grantedBy User? @relation("AccessLogAdmin", fields: [grantedById], references: [id], onDelete: SetNull)
}
model LessonProgress {
userId String
lessonId String
+48
View File
@@ -0,0 +1,48 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
}
function slugify(str: string) {
const map: Record<string, string> = {
а:"a",б:"b",в:"v",г:"g",д:"d",е:"e",ё:"yo",ж:"zh",з:"z",и:"i",й:"y",
к:"k",л:"l",м:"m",н:"n",о:"o",п:"p",р:"r",с:"s",т:"t",у:"u",ф:"f",
х:"kh",ц:"ts",ч:"ch",ш:"sh",щ:"shch",ъ:"",ы:"y",ь:"",э:"e",ю:"yu",я:"ya",
};
return str.toLowerCase()
.replace(/[а-яё]/g, (c) => map[c] ?? c)
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export async function createCategory(formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const slug = (formData.get("slug") as string).trim() || slugify(title);
const count = await prisma.category.count();
await prisma.category.create({ data: { title, slug, order: count } });
revalidatePath("/admin/categories");
}
export async function updateCategory(id: string, formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const slug = formData.get("slug") as string;
await prisma.category.update({ where: { id }, data: { title, slug } });
revalidatePath("/admin/categories");
}
export async function deleteCategory(id: string) {
await requireAdmin();
await prisma.category.delete({ where: { id } });
revalidatePath("/admin/categories");
revalidatePath("/admin/courses");
}
+60
View File
@@ -0,0 +1,60 @@
import { prisma } from "@/lib/prisma";
import { CategoryRow } from "@/components/admin/category-row";
import { createCategory } from "./actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export default async function CategoriesPage() {
const categories = await prisma.category.findMany({
orderBy: { order: "asc" },
include: { _count: { select: { courses: true } } },
});
return (
<div className="p-8 max-w-2xl">
<div className="mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--foreground)" }}>Категории</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--muted-foreground)" }}>
{categories.length} категорий
</p>
</div>
<div className="space-y-2 mb-8">
{categories.length === 0 ? (
<p className="text-sm py-4" style={{ color: "var(--muted-foreground)" }}>
Категорий пока нет. Создайте первую.
</p>
) : (
categories.map((cat) => (
<CategoryRow key={cat.id} category={cat} courseCount={cat._count.courses} />
))
)}
</div>
<div className="card-aubade p-5">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
Новая категория
</p>
<form action={createCategory} className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs uppercase tracking-widest font-bold block mb-1.5" style={{ color: "var(--muted-foreground)" }}>
Название
</label>
<Input name="title" placeholder="Obsidian PKM" required />
</div>
<div>
<label className="text-xs uppercase tracking-widest font-bold block mb-1.5" style={{ color: "var(--muted-foreground)" }}>
Slug
</label>
<Input name="slug" placeholder="obsidian-pkm (авто)" />
</div>
</div>
<div className="flex justify-end">
<Button type="submit">+ Создать</Button>
</div>
</form>
</div>
</div>
);
}
+32 -6
View File
@@ -8,6 +8,7 @@ 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;
}
// ── Modules ──────────────────────────────────────────────────────────────────
@@ -45,20 +46,45 @@ export async function reorderModules(courseId: string, orderedIds: string[]) {
// ── Enrollment ───────────────────────────────────────────────────────────────
export async function grantAccess(courseId: string, userId: string) {
await requireAdmin();
export async function grantAccess(
courseId: string,
userId: string,
expiresAt?: string | null,
note?: string
) {
const session = await requireAdmin();
await prisma.courseEnrollment.upsert({
where: { userId_courseId: { userId, courseId } },
update: {},
create: { userId, courseId },
update: { expiresAt: expiresAt ? new Date(expiresAt) : null },
create: { userId, courseId, expiresAt: expiresAt ? new Date(expiresAt) : null },
});
await prisma.accessLog.create({
data: {
courseId,
userId,
action: "granted",
method: "manual",
grantedById: session.user.id,
note: note || null,
},
});
revalidatePath(`/admin/courses/${courseId}`);
}
export async function revokeAccess(courseId: string, userId: string) {
await requireAdmin();
export async function revokeAccess(courseId: string, userId: string, note?: string) {
const session = await requireAdmin();
await prisma.courseEnrollment.delete({
where: { userId_courseId: { userId, courseId } },
});
await prisma.accessLog.create({
data: {
courseId,
userId,
action: "revoked",
method: "manual",
grantedById: session.user.id,
note: note || null,
},
});
revalidatePath(`/admin/courses/${courseId}`);
}
+35 -17
View File
@@ -12,7 +12,7 @@ interface Props {
export default async function CourseDetailPage({ params }: Props) {
const { courseId } = await params;
const [course, allStudents] = await Promise.all([
const [course, allStudents, categories] = await Promise.all([
prisma.course.findUnique({
where: { id: courseId },
include: {
@@ -20,7 +20,17 @@ export default async function CourseDetailPage({ params }: Props) {
orderBy: { order: "asc" },
include: { _count: { select: { lessons: true } } },
},
enrollments: { include: { user: { select: { id: true, name: true, email: true } } } },
enrollments: {
select: { userId: true, expiresAt: true },
},
accessLogs: {
orderBy: { createdAt: "desc" },
take: 50,
include: {
user: { select: { name: true } },
grantedBy: { select: { name: true } },
},
},
},
}),
prisma.user.findMany({
@@ -28,43 +38,51 @@ export default async function CourseDetailPage({ params }: Props) {
select: { id: true, name: true, email: true },
orderBy: { name: "asc" },
}),
prisma.category.findMany({ orderBy: { order: "asc" } }),
]);
if (!course) notFound();
const enrolledIds = new Set(course.enrollments.map((e) => e.userId));
return (
<div className="p-8 max-w-4xl">
{/* Breadcrumb */}
<nav className="text-sm text-slate-400 mb-6">
<Link href="/admin/courses" className="hover:text-slate-600">Курсы</Link>
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/admin/courses" className="hover:underline">Курсы</Link>
<span className="mx-2">/</span>
<span className="text-slate-700">{course.title}</span>
<span style={{ color: "var(--foreground)" }}>{course.title}</span>
</nav>
{/* Course metadata */}
<section className="bg-white border border-slate-200 rounded-2xl p-6 mb-6">
<h2 className="text-base font-semibold text-slate-700 mb-4">Основная информация</h2>
<CourseEditForm course={course} />
<section className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Основная информация
</p>
<CourseEditForm course={course} categories={categories} />
</section>
{/* Modules */}
<section className="bg-white border border-slate-200 rounded-2xl p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-slate-700">Модули</h2>
<span className="text-sm text-slate-400">{course.modules.length} модулей</span>
<section className="card-aubade p-6 mb-6">
<div className="flex items-center justify-between mb-5">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Модули
</p>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{course.modules.length} модулей
</span>
</div>
<SortableModules courseId={courseId} modules={course.modules} />
</section>
{/* Access management */}
<section className="bg-white border border-slate-200 rounded-2xl p-6">
<h2 className="text-base font-semibold text-slate-700 mb-4">Доступ к курсу</h2>
<section className="card-aubade p-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Управление доступом
</p>
<EnrollmentManager
courseId={courseId}
allStudents={allStudents}
enrolledIds={[...enrolledIds]}
enrollments={course.enrollments}
accessLogs={course.accessLogs}
/>
</section>
</div>
+2 -1
View File
@@ -44,10 +44,11 @@ export async function updateCourse(courseId: string, formData: FormData) {
const description = (formData.get("description") as string) || null;
const published = formData.get("published") === "true";
const coverImage = (formData.get("coverImage") as string) || null;
const categoryId = (formData.get("categoryId") as string) || null;
await prisma.course.update({
where: { id: courseId },
data: { title, slug, description, published, coverImage },
data: { title, slug, description, published, coverImage, categoryId },
});
revalidatePath("/admin/courses");
+59
View File
@@ -0,0 +1,59 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
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 bulkGrantAccess(
userId: string,
courseIds: string[],
expiresAt?: string | null
) {
const session = await requireAdmin();
const expiry = expiresAt ? new Date(expiresAt) : null;
await Promise.all(
courseIds.map(async (courseId) => {
await prisma.courseEnrollment.upsert({
where: { userId_courseId: { userId, courseId } },
update: { expiresAt: expiry },
create: { userId, courseId, expiresAt: expiry },
});
await prisma.accessLog.create({
data: {
courseId,
userId,
action: "granted",
method: "bulk",
grantedById: session.user.id,
},
});
})
);
revalidatePath(`/admin/users/${userId}`);
}
export async function revokeUserAccess(userId: string, courseId: string) {
const session = await requireAdmin();
await prisma.courseEnrollment.delete({
where: { userId_courseId: { userId, courseId } },
});
await prisma.accessLog.create({
data: {
courseId,
userId,
action: "revoked",
method: "manual",
grantedById: session.user.id,
},
});
revalidatePath(`/admin/users/${userId}`);
}
+105
View File
@@ -0,0 +1,105 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { UserEnrollmentManager } from "@/components/admin/user-enrollment-manager";
interface Props {
params: Promise<{ userId: string }>;
}
export default async function UserPage({ params }: Props) {
const { userId } = await params;
const [user, allCourses] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
include: {
enrollments: {
include: { course: { select: { id: true, title: true, published: true } } },
orderBy: { enrolledAt: "desc" },
},
accessLogs: {
orderBy: { createdAt: "desc" },
take: 30,
include: {
course: { select: { title: true } },
grantedBy: { select: { name: true } },
},
},
},
}),
prisma.course.findMany({
orderBy: { title: "asc" },
select: { id: true, title: true, published: true },
}),
]);
if (!user) notFound();
const roleLabel: Record<string, string> = { admin: "Администратор", curator: "Куратор", student: "Ученик" };
return (
<div className="p-8 max-w-3xl">
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/admin/users" className="hover:underline">Пользователи</Link>
<span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>{user.name}</span>
</nav>
{/* User info */}
<section className="card-aubade p-6 mb-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-bold">{user.name}</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--muted-foreground)" }}>{user.email}</p>
</div>
<div className="flex flex-col items-end gap-1">
<span className="tag-aubade">{roleLabel[user.role] ?? user.role}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
с {new Date(user.createdAt).toLocaleDateString("ru-RU")}
</span>
</div>
</div>
</section>
{/* Enrollments + bulk grant */}
<section className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Доступ к курсам
</p>
<UserEnrollmentManager
userId={userId}
allCourses={allCourses}
enrollments={user.enrollments.map((e) => ({
courseId: e.courseId,
expiresAt: e.expiresAt,
courseTitle: e.course.title,
}))}
/>
</section>
{/* Access log */}
{user.accessLogs.length > 0 && (
<section className="card-aubade p-6">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
История доступа
</p>
<div className="space-y-1.5 max-h-72 overflow-y-auto">
{user.accessLogs.map((log) => (
<div key={log.id} className="flex items-center gap-3 px-3 py-2 text-xs" style={{ border: "2px solid var(--border)" }}>
<span style={{ color: log.action === "granted" ? "#3A6A3A" : "oklch(0.577 0.245 27.325)", fontWeight: 700, minWidth: 70 }}>
{log.action === "granted" ? "▲ Выдан" : "▼ Отозван"}
</span>
<span className="flex-1">{log.course.title}</span>
<span style={{ color: "var(--muted-foreground)" }}>{log.grantedBy?.name ?? "—"}</span>
<span style={{ color: "var(--muted-foreground)" }}>
{new Date(log.createdAt).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
</span>
</div>
))}
</div>
</section>
)}
</div>
);
}
+4 -3
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
const roleLabel: Record<string, string> = {
admin: "Администратор",
@@ -39,10 +40,10 @@ export default async function UsersPage() {
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-slate-50 last:border-0 hover:bg-slate-50">
<tr key={user.id} className="border-b last:border-0" style={{ borderColor: "var(--border)" }}>
<td className="px-5 py-3">
<p className="font-medium text-slate-800">{user.name}</p>
<p className="text-xs text-slate-400">{user.email}</p>
<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"}>
+1
View File
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
const links = [
{ href: "/admin/dashboard", label: "Обзор" },
{ href: "/admin/courses", label: "Курсы" },
{ href: "/admin/categories", label: "Категории" },
{ href: "/admin/users", label: "Пользователи" },
];
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useState, useTransition } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { updateCategory, deleteCategory } from "@/app/admin/categories/actions";
interface Props {
category: { id: string; title: string; slug: string };
courseCount: number;
}
export function CategoryRow({ category, courseCount }: Props) {
const [editing, setEditing] = useState(false);
const [pending, startTransition] = useTransition();
function handleUpdate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
startTransition(() => updateCategory(category.id, fd));
setEditing(false);
}
function handleDelete() {
if (courseCount > 0) {
alert(`Нельзя удалить: к категории привязано ${courseCount} курсов`);
return;
}
if (!confirm(`Удалить категорию «${category.title}»?`)) return;
startTransition(() => deleteCategory(category.id));
}
return (
<div className="flex items-center gap-3 px-4 py-3" style={{ border: "2px solid var(--border)", background: "var(--background)" }}>
{editing ? (
<form onSubmit={handleUpdate} className="flex items-center gap-2 flex-1">
<Input name="title" defaultValue={category.title} required className="h-8 text-sm" />
<Input name="slug" defaultValue={category.slug} className="h-8 text-sm w-36" />
<Button type="submit" size="sm" disabled={pending}>Сохранить</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}></Button>
</form>
) : (
<>
<span className="flex-1 font-medium" style={{ color: "var(--foreground)" }}>{category.title}</span>
<span className="text-xs font-mono" style={{ color: "var(--muted-foreground)" }}>/{category.slug}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>{courseCount} курсов</span>
<button onClick={() => setEditing(true)} className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
Редакт.
</button>
<button onClick={handleDelete} disabled={pending} className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
Удалить
</button>
</>
)}
</div>
);
}
+26 -1
View File
@@ -14,11 +14,18 @@ interface Course {
description: string | null;
coverImage: string | null;
published: boolean;
categoryId: string | null;
}
export function CourseEditForm({ course }: { course: Course }) {
interface Category {
id: string;
title: string;
}
export function CourseEditForm({ course, categories = [] }: { course: Course; categories?: Category[] }) {
const [published, setPublished] = useState(course.published);
const [coverImage, setCoverImage] = useState(course.coverImage ?? "");
const [categoryId, setCategoryId] = useState(course.categoryId ?? "");
const [uploading, setUploading] = useState(false);
const [pending, startTransition] = useTransition();
@@ -39,6 +46,7 @@ export function CourseEditForm({ course }: { course: Course }) {
const fd = new FormData(e.currentTarget);
fd.set("published", String(published));
fd.set("coverImage", coverImage);
fd.set("categoryId", categoryId);
startTransition(() => updateCourse(course.id, fd));
}
@@ -63,6 +71,23 @@ export function CourseEditForm({ course }: { course: Course }) {
<Label htmlFor="description">Описание</Label>
<Textarea id="description" name="description" defaultValue={course.description ?? ""} rows={3} />
</div>
{categories.length > 0 && (
<div className="space-y-1.5">
<Label htmlFor="categoryId">Категория</Label>
<select
id="categoryId"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="w-full px-3 py-2 text-sm bg-transparent"
style={{ border: "2px solid var(--border)", color: "var(--foreground)", fontFamily: "var(--font-sans)" }}
>
<option value="">Без категории</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.title}</option>
))}
</select>
</div>
)}
<div className="space-y-1.5">
<Label>Обложка</Label>
<div className="flex items-center gap-3">
+142 -50
View File
@@ -3,7 +3,6 @@
import { useState, useTransition } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { grantAccess, revokeAccess } from "@/app/admin/courses/[courseId]/actions";
interface Student {
@@ -12,15 +11,35 @@ interface Student {
email: string;
}
interface Enrollment {
userId: string;
expiresAt: Date | null;
}
interface LogEntry {
id: string;
action: string;
createdAt: Date;
note: string | null;
user: { name: string };
grantedBy: { name: string } | null;
}
interface Props {
courseId: string;
allStudents: Student[];
enrolledIds: string[];
enrollments: Enrollment[];
accessLogs: LogEntry[];
}
export function EnrollmentManager({ courseId, allStudents, enrolledIds }: Props) {
const [enrolled, setEnrolled] = useState(new Set(enrolledIds));
export function EnrollmentManager({ courseId, allStudents, enrollments, accessLogs }: Props) {
const [enrolledMap, setEnrolledMap] = useState<Map<string, Date | null>>(
() => new Map(enrollments.map((e) => [e.userId, e.expiresAt]))
);
const [search, setSearch] = useState("");
const [expiryDate, setExpiryDate] = useState("");
const [note, setNote] = useState("");
const [showLog, setShowLog] = useState(false);
const [pending, startTransition] = useTransition();
const filtered = allStudents.filter(
@@ -29,70 +48,143 @@ export function EnrollmentManager({ courseId, allStudents, enrolledIds }: Props)
s.email.toLowerCase().includes(search.toLowerCase())
);
function toggle(userId: string) {
if (enrolled.has(userId)) {
setEnrolled((prev) => { const s = new Set(prev); s.delete(userId); return s; });
startTransition(() => revokeAccess(courseId, userId));
} else {
setEnrolled((prev) => new Set(prev).add(userId));
startTransition(() => grantAccess(courseId, userId));
}
function handleGrant(userId: string) {
const newMap = new Map(enrolledMap);
newMap.set(userId, expiryDate ? new Date(expiryDate) : null);
setEnrolledMap(newMap);
startTransition(() => grantAccess(courseId, userId, expiryDate || null, note || undefined));
}
const enrolledStudents = allStudents.filter((s) => enrolled.has(s.id));
function handleRevoke(userId: string) {
const newMap = new Map(enrolledMap);
newMap.delete(userId);
setEnrolledMap(newMap);
startTransition(() => revokeAccess(courseId, userId, note || undefined));
}
const enrolledStudents = allStudents.filter((s) => enrolledMap.has(s.id));
function formatExpiry(date: Date | null) {
if (!date) return "Бессрочно";
const d = new Date(date);
const now = new Date();
const expired = d < now;
return (
<span style={{ color: expired ? "oklch(0.577 0.245 27.325)" : "var(--foreground)" }}>
{expired ? "Истёк " : "До "}
{d.toLocaleDateString("ru-RU")}
</span>
);
}
return (
<div className="space-y-4">
<div className="space-y-5">
{/* Enrolled list */}
{enrolledStudents.length > 0 && (
<div>
<p className="text-sm text-slate-500 mb-2">Доступ открыт ({enrolledStudents.length}):</p>
<div className="flex flex-wrap gap-2">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Доступ открыт {enrolledStudents.length}
</p>
<div className="space-y-1.5">
{enrolledStudents.map((s) => (
<Badge key={s.id} variant="secondary" className="gap-1.5 py-1 pr-1">
{s.name}
<button
onClick={() => toggle(s.id)}
disabled={pending}
className="ml-1 text-slate-400 hover:text-red-500"
>
</button>
</Badge>
<div key={s.id} className="flex items-center justify-between px-3 py-2" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
<div>
<p className="text-sm font-medium">{s.name}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{s.email}</p>
</div>
<div className="flex items-center gap-3">
<span className="text-xs">{formatExpiry(enrolledMap.get(s.id) ?? null)}</span>
<button onClick={() => handleRevoke(s.id)} disabled={pending} className="text-xs underline" style={{ color: "oklch(0.577 0.245 27.325)" }}>
Отозвать
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Grant form */}
<div>
<p className="text-sm text-slate-500 mb-2">Добавить ученика:</p>
<Input
placeholder="Поиск по имени или email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm mb-3"
/>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{filtered.map((student) => (
<div key={student.id} className="flex items-center justify-between px-3 py-2 rounded-lg border border-slate-100 bg-slate-50">
<div>
<p className="text-sm font-medium text-slate-700">{student.name}</p>
<p className="text-xs text-slate-400">{student.email}</p>
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Добавить ученика
</p>
<div className="flex gap-3 mb-3 flex-wrap">
<div className="flex-1 min-w-48">
<label className="text-xs uppercase tracking-wider mb-1 block" style={{ color: "var(--muted-foreground)" }}>Поиск</label>
<Input placeholder="Имя или email..." value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<div>
<label className="text-xs uppercase tracking-wider mb-1 block" style={{ color: "var(--muted-foreground)" }}>
Срок доступа
</label>
<Input type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.target.value)} className="w-44" />
</div>
<div className="flex-1 min-w-40">
<label className="text-xs uppercase tracking-wider mb-1 block" style={{ color: "var(--muted-foreground)" }}>
Примечание
</label>
<Input placeholder="Оплата, договор..." value={note} onChange={(e) => setNote(e.target.value)} />
</div>
</div>
<div className="space-y-1.5 max-h-52 overflow-y-auto">
{filtered.map((student) => {
const enrolled = enrolledMap.has(student.id);
return (
<div key={student.id} className="flex items-center justify-between px-3 py-2" style={{ border: "2px solid var(--border)", background: "var(--background)" }}>
<div>
<p className="text-sm font-medium">{student.name}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{student.email}</p>
</div>
{enrolled ? (
<button onClick={() => handleRevoke(student.id)} disabled={pending} className="text-xs px-3 py-1.5" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>
Отозвать
</button>
) : (
<button onClick={() => handleGrant(student.id)} disabled={pending} className="btn-aubade text-xs py-1.5 px-3">
Дать доступ
</button>
)}
</div>
<Button
size="sm"
variant={enrolled.has(student.id) ? "destructive" : "outline"}
onClick={() => toggle(student.id)}
disabled={pending}
>
{enrolled.has(student.id) ? "Убрать" : "Дать доступ"}
</Button>
</div>
))}
);
})}
{filtered.length === 0 && (
<p className="text-sm text-slate-400 py-2">Студентов не найдено</p>
<p className="text-sm py-2" style={{ color: "var(--muted-foreground)" }}>Студентов не найдено</p>
)}
</div>
</div>
{/* Access log */}
{accessLogs.length > 0 && (
<div>
<button
onClick={() => setShowLog(!showLog)}
className="text-xs font-bold uppercase tracking-widest underline"
style={{ color: "var(--muted-foreground)" }}
>
История доступа ({accessLogs.length}) {showLog ? "▲" : "▼"}
</button>
{showLog && (
<div className="mt-3 space-y-1.5 max-h-64 overflow-y-auto">
{accessLogs.map((log) => (
<div key={log.id} className="flex items-start gap-3 px-3 py-2 text-xs" style={{ border: "2px solid var(--border)", background: "var(--background)" }}>
<span style={{ color: log.action === "granted" ? "#3A6A3A" : "oklch(0.577 0.245 27.325)", fontWeight: 700 }}>
{log.action === "granted" ? "▲ Выдан" : "▼ Отозван"}
</span>
<span className="flex-1">{log.user.name}</span>
<span style={{ color: "var(--muted-foreground)" }}>
{log.grantedBy?.name ?? "—"}
</span>
{log.note && <span style={{ color: "var(--muted-foreground)" }}>{log.note}</span>}
<span style={{ color: "var(--muted-foreground)" }}>
{new Date(log.createdAt).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,137 @@
"use client";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { bulkGrantAccess, revokeUserAccess } from "@/app/admin/users/[userId]/actions";
interface Course {
id: string;
title: string;
published: boolean;
}
interface Enrollment {
courseId: string;
expiresAt: Date | null;
courseTitle: string;
}
interface Props {
userId: string;
allCourses: Course[];
enrollments: Enrollment[];
}
export function UserEnrollmentManager({ userId, allCourses, enrollments }: Props) {
const [enrolledMap, setEnrolledMap] = useState<Map<string, Date | null>>(
() => new Map(enrollments.map((e) => [e.courseId, e.expiresAt]))
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [expiryDate, setExpiryDate] = useState("");
const [pending, startTransition] = useTransition();
const unenrolled = allCourses.filter((c) => !enrolledMap.has(c.id));
function toggleSelect(courseId: string) {
setSelected((prev) => {
const next = new Set(prev);
next.has(courseId) ? next.delete(courseId) : next.add(courseId);
return next;
});
}
function handleBulkGrant() {
if (selected.size === 0) return;
const ids = [...selected];
const expiry = expiryDate || null;
const newMap = new Map(enrolledMap);
ids.forEach((id) => newMap.set(id, expiry ? new Date(expiry) : null));
setEnrolledMap(newMap);
setSelected(new Set());
startTransition(() => bulkGrantAccess(userId, ids, expiry));
}
function handleRevoke(courseId: string) {
const newMap = new Map(enrolledMap);
newMap.delete(courseId);
setEnrolledMap(newMap);
startTransition(() => revokeUserAccess(userId, courseId));
}
function formatExpiry(date: Date | null) {
if (!date) return "Бессрочно";
const d = new Date(date);
const expired = d < new Date();
return (
<span style={{ color: expired ? "oklch(0.577 0.245 27.325)" : "inherit" }}>
{expired ? "Истёк " : "До "}{d.toLocaleDateString("ru-RU")}
</span>
);
}
const enrolledCourses = allCourses.filter((c) => enrolledMap.has(c.id));
return (
<div className="space-y-5">
{/* Current enrollments */}
{enrolledCourses.length > 0 ? (
<div className="space-y-1.5">
{enrolledCourses.map((c) => (
<div key={c.id} className="flex items-center justify-between px-3 py-2.5 text-sm" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
<span className="font-medium">{c.title}</span>
<div className="flex items-center gap-4">
<span className="text-xs">{formatExpiry(enrolledMap.get(c.id) ?? null)}</span>
<button onClick={() => handleRevoke(c.id)} disabled={pending} className="text-xs underline" style={{ color: "oklch(0.577 0.245 27.325)" }}>
Отозвать
</button>
</div>
</div>
))}
</div>
) : (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>Доступа к курсам нет</p>
)}
{/* Bulk grant */}
{unenrolled.length > 0 && (
<div>
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Добавить курсы
</p>
<div className="space-y-1.5 max-h-48 overflow-y-auto mb-3">
{unenrolled.map((c) => (
<label key={c.id} className="flex items-center gap-3 px-3 py-2 cursor-pointer text-sm" style={{ border: "2px solid var(--border)", background: selected.has(c.id) ? "var(--color-highlight)" : "var(--background)" }}>
<input
type="checkbox"
checked={selected.has(c.id)}
onChange={() => toggleSelect(c.id)}
className="accent-current"
/>
<span>{c.title}</span>
{!c.published && (
<span className="text-xs ml-auto" style={{ color: "var(--muted-foreground)" }}>черновик</span>
)}
</label>
))}
</div>
{selected.size > 0 && (
<div className="flex items-center gap-3">
<div>
<label className="text-xs uppercase tracking-wider block mb-1" style={{ color: "var(--muted-foreground)" }}>
Срок доступа
</label>
<Input type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.target.value)} className="w-44" />
</div>
<div className="pt-5">
<button onClick={handleBulkGrant} disabled={pending} className="btn-aubade btn-aubade-accent">
Дать доступ к {selected.size} курсам
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}