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) ## Этап 1.5 — Расширенное управление доступом
**Цель:** способы выдачи доступа как в emdesell — ключи активации, срок доступа, категории. **Цель:** гибкое управление доступом: сроки, категории, пакеты, история.
- [ ] **Ключи активации:** генерировать N одноразовых кодов для курса → ученик вводит код и получает доступ
- [ ] **Срок доступа:** поле `expiresAt` в `CourseEnrollment` + автоблокировка по дате - [ ] **Срок доступа:** поле `expiresAt` в `CourseEnrollment` + автоблокировка по дате
- [ ] **Категории курсов:** таблица `Category`, поле `categoryId` в `Course`, фильтрация в списке - [ ] **Категории курсов:** таблица `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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sessions Session[] sessions Session[]
accounts Account[] accounts Account[]
enrollments CourseEnrollment[] enrollments CourseEnrollment[]
progress LessonProgress[] progress LessonProgress[]
submissions HomeworkSubmission[] submissions HomeworkSubmission[]
comments LessonComment[] comments LessonComment[]
feedbacks HomeworkFeedback[] feedbacks HomeworkFeedback[]
accessLogs AccessLog[] @relation("AccessLogUser")
adminAccessLogs AccessLog[] @relation("AccessLogAdmin")
} }
model Session { model Session {
@@ -77,6 +79,16 @@ model Verification {
// LMS core tables // 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 { model Course {
id String @id @default(cuid()) id String @id @default(cuid())
slug String @unique slug String @unique
@@ -85,11 +97,14 @@ model Course {
coverImage String? coverImage String?
published Boolean @default(false) published Boolean @default(false)
order Int @default(0) order Int @default(0)
categoryId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
modules Module[] modules Module[]
enrollments CourseEnrollment[] enrollments CourseEnrollment[]
accessLogs AccessLog[]
} }
model Module { model Module {
@@ -137,7 +152,8 @@ model LessonFile {
model CourseEnrollment { model CourseEnrollment {
userId String userId String
courseId String courseId String
enrolledAt DateTime @default(now()) enrolledAt DateTime @default(now())
expiresAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@ -145,6 +161,21 @@ model CourseEnrollment {
@@id([userId, courseId]) @@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 { model LessonProgress {
userId String userId String
lessonId 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() { async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Forbidden"); if (!session || session.user.role !== "admin") throw new Error("Forbidden");
return session;
} }
// ── Modules ────────────────────────────────────────────────────────────────── // ── Modules ──────────────────────────────────────────────────────────────────
@@ -45,20 +46,45 @@ export async function reorderModules(courseId: string, orderedIds: string[]) {
// ── Enrollment ─────────────────────────────────────────────────────────────── // ── Enrollment ───────────────────────────────────────────────────────────────
export async function grantAccess(courseId: string, userId: string) { export async function grantAccess(
await requireAdmin(); courseId: string,
userId: string,
expiresAt?: string | null,
note?: string
) {
const session = await requireAdmin();
await prisma.courseEnrollment.upsert({ await prisma.courseEnrollment.upsert({
where: { userId_courseId: { userId, courseId } }, where: { userId_courseId: { userId, courseId } },
update: {}, update: { expiresAt: expiresAt ? new Date(expiresAt) : null },
create: { userId, courseId }, 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}`); revalidatePath(`/admin/courses/${courseId}`);
} }
export async function revokeAccess(courseId: string, userId: string) { export async function revokeAccess(courseId: string, userId: string, note?: string) {
await requireAdmin(); const session = await requireAdmin();
await prisma.courseEnrollment.delete({ await prisma.courseEnrollment.delete({
where: { userId_courseId: { userId, courseId } }, 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}`); revalidatePath(`/admin/courses/${courseId}`);
} }
+35 -17
View File
@@ -12,7 +12,7 @@ interface Props {
export default async function CourseDetailPage({ params }: Props) { export default async function CourseDetailPage({ params }: Props) {
const { courseId } = await params; const { courseId } = await params;
const [course, allStudents] = await Promise.all([ const [course, allStudents, categories] = await Promise.all([
prisma.course.findUnique({ prisma.course.findUnique({
where: { id: courseId }, where: { id: courseId },
include: { include: {
@@ -20,7 +20,17 @@ export default async function CourseDetailPage({ params }: Props) {
orderBy: { order: "asc" }, orderBy: { order: "asc" },
include: { _count: { select: { lessons: true } } }, 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({ prisma.user.findMany({
@@ -28,43 +38,51 @@ export default async function CourseDetailPage({ params }: Props) {
select: { id: true, name: true, email: true }, select: { id: true, name: true, email: true },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}), }),
prisma.category.findMany({ orderBy: { order: "asc" } }),
]); ]);
if (!course) notFound(); if (!course) notFound();
const enrolledIds = new Set(course.enrollments.map((e) => e.userId));
return ( return (
<div className="p-8 max-w-4xl"> <div className="p-8 max-w-4xl">
{/* Breadcrumb */} {/* Breadcrumb */}
<nav className="text-sm text-slate-400 mb-6"> <nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/admin/courses" className="hover:text-slate-600">Курсы</Link> <Link href="/admin/courses" className="hover:underline">Курсы</Link>
<span className="mx-2">/</span> <span className="mx-2">/</span>
<span className="text-slate-700">{course.title}</span> <span style={{ color: "var(--foreground)" }}>{course.title}</span>
</nav> </nav>
{/* Course metadata */} {/* Course metadata */}
<section className="bg-white border border-slate-200 rounded-2xl p-6 mb-6"> <section className="card-aubade p-6 mb-6">
<h2 className="text-base font-semibold text-slate-700 mb-4">Основная информация</h2> <p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
<CourseEditForm course={course} /> Основная информация
</p>
<CourseEditForm course={course} categories={categories} />
</section> </section>
{/* Modules */} {/* Modules */}
<section className="bg-white border border-slate-200 rounded-2xl p-6 mb-6"> <section className="card-aubade p-6 mb-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-5">
<h2 className="text-base font-semibold text-slate-700">Модули</h2> <p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<span className="text-sm text-slate-400">{course.modules.length} модулей</span> Модули
</p>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{course.modules.length} модулей
</span>
</div> </div>
<SortableModules courseId={courseId} modules={course.modules} /> <SortableModules courseId={courseId} modules={course.modules} />
</section> </section>
{/* Access management */} {/* Access management */}
<section className="bg-white border border-slate-200 rounded-2xl p-6"> <section className="card-aubade p-6">
<h2 className="text-base font-semibold text-slate-700 mb-4">Доступ к курсу</h2> <p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Управление доступом
</p>
<EnrollmentManager <EnrollmentManager
courseId={courseId} courseId={courseId}
allStudents={allStudents} allStudents={allStudents}
enrolledIds={[...enrolledIds]} enrollments={course.enrollments}
accessLogs={course.accessLogs}
/> />
</section> </section>
</div> </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 description = (formData.get("description") as string) || null;
const published = formData.get("published") === "true"; const published = formData.get("published") === "true";
const coverImage = (formData.get("coverImage") as string) || null; const coverImage = (formData.get("coverImage") as string) || null;
const categoryId = (formData.get("categoryId") as string) || null;
await prisma.course.update({ await prisma.course.update({
where: { id: courseId }, where: { id: courseId },
data: { title, slug, description, published, coverImage }, data: { title, slug, description, published, coverImage, categoryId },
}); });
revalidatePath("/admin/courses"); 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 { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import Link from "next/link";
const roleLabel: Record<string, string> = { const roleLabel: Record<string, string> = {
admin: "Администратор", admin: "Администратор",
@@ -39,10 +40,10 @@ export default async function UsersPage() {
</thead> </thead>
<tbody> <tbody>
{users.map((user) => ( {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"> <td className="px-5 py-3">
<p className="font-medium text-slate-800">{user.name}</p> <Link href={`/admin/users/${user.id}`} className="font-medium hover:underline" style={{ color: "var(--foreground)" }}>{user.name}</Link>
<p className="text-xs text-slate-400">{user.email}</p> <p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{user.email}</p>
</td> </td>
<td className="px-5 py-3"> <td className="px-5 py-3">
<Badge variant={roleVariant[user.role] ?? "outline"}> <Badge variant={roleVariant[user.role] ?? "outline"}>
+1
View File
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
const links = [ const links = [
{ href: "/admin/dashboard", label: "Обзор" }, { href: "/admin/dashboard", label: "Обзор" },
{ href: "/admin/courses", label: "Курсы" }, { href: "/admin/courses", label: "Курсы" },
{ href: "/admin/categories", label: "Категории" },
{ href: "/admin/users", 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; description: string | null;
coverImage: string | null; coverImage: string | null;
published: boolean; 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 [published, setPublished] = useState(course.published);
const [coverImage, setCoverImage] = useState(course.coverImage ?? ""); const [coverImage, setCoverImage] = useState(course.coverImage ?? "");
const [categoryId, setCategoryId] = useState(course.categoryId ?? "");
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
@@ -39,6 +46,7 @@ export function CourseEditForm({ course }: { course: Course }) {
const fd = new FormData(e.currentTarget); const fd = new FormData(e.currentTarget);
fd.set("published", String(published)); fd.set("published", String(published));
fd.set("coverImage", coverImage); fd.set("coverImage", coverImage);
fd.set("categoryId", categoryId);
startTransition(() => updateCourse(course.id, fd)); startTransition(() => updateCourse(course.id, fd));
} }
@@ -63,6 +71,23 @@ export function CourseEditForm({ course }: { course: Course }) {
<Label htmlFor="description">Описание</Label> <Label htmlFor="description">Описание</Label>
<Textarea id="description" name="description" defaultValue={course.description ?? ""} rows={3} /> <Textarea id="description" name="description" defaultValue={course.description ?? ""} rows={3} />
</div> </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"> <div className="space-y-1.5">
<Label>Обложка</Label> <Label>Обложка</Label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
+142 -50
View File
@@ -3,7 +3,6 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { grantAccess, revokeAccess } from "@/app/admin/courses/[courseId]/actions"; import { grantAccess, revokeAccess } from "@/app/admin/courses/[courseId]/actions";
interface Student { interface Student {
@@ -12,15 +11,35 @@ interface Student {
email: string; 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 { interface Props {
courseId: string; courseId: string;
allStudents: Student[]; allStudents: Student[];
enrolledIds: string[]; enrollments: Enrollment[];
accessLogs: LogEntry[];
} }
export function EnrollmentManager({ courseId, allStudents, enrolledIds }: Props) { export function EnrollmentManager({ courseId, allStudents, enrollments, accessLogs }: Props) {
const [enrolled, setEnrolled] = useState(new Set(enrolledIds)); const [enrolledMap, setEnrolledMap] = useState<Map<string, Date | null>>(
() => new Map(enrollments.map((e) => [e.userId, e.expiresAt]))
);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [expiryDate, setExpiryDate] = useState("");
const [note, setNote] = useState("");
const [showLog, setShowLog] = useState(false);
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const filtered = allStudents.filter( const filtered = allStudents.filter(
@@ -29,70 +48,143 @@ export function EnrollmentManager({ courseId, allStudents, enrolledIds }: Props)
s.email.toLowerCase().includes(search.toLowerCase()) s.email.toLowerCase().includes(search.toLowerCase())
); );
function toggle(userId: string) { function handleGrant(userId: string) {
if (enrolled.has(userId)) { const newMap = new Map(enrolledMap);
setEnrolled((prev) => { const s = new Set(prev); s.delete(userId); return s; }); newMap.set(userId, expiryDate ? new Date(expiryDate) : null);
startTransition(() => revokeAccess(courseId, userId)); setEnrolledMap(newMap);
} else { startTransition(() => grantAccess(courseId, userId, expiryDate || null, note || undefined));
setEnrolled((prev) => new Set(prev).add(userId));
startTransition(() => grantAccess(courseId, userId));
}
} }
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 ( return (
<div className="space-y-4"> <div className="space-y-5">
{/* Enrolled list */}
{enrolledStudents.length > 0 && ( {enrolledStudents.length > 0 && (
<div> <div>
<p className="text-sm text-slate-500 mb-2">Доступ открыт ({enrolledStudents.length}):</p> <p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
<div className="flex flex-wrap gap-2"> Доступ открыт {enrolledStudents.length}
</p>
<div className="space-y-1.5">
{enrolledStudents.map((s) => ( {enrolledStudents.map((s) => (
<Badge key={s.id} variant="secondary" className="gap-1.5 py-1 pr-1"> <div key={s.id} className="flex items-center justify-between px-3 py-2" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
{s.name} <div>
<button <p className="text-sm font-medium">{s.name}</p>
onClick={() => toggle(s.id)} <p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{s.email}</p>
disabled={pending} </div>
className="ml-1 text-slate-400 hover:text-red-500" <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> Отозвать
</Badge> </button>
</div>
</div>
))} ))}
</div> </div>
</div> </div>
)} )}
{/* Grant form */}
<div> <div>
<p className="text-sm text-slate-500 mb-2">Добавить ученика:</p> <p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
<Input Добавить ученика
placeholder="Поиск по имени или email..." </p>
value={search} <div className="flex gap-3 mb-3 flex-wrap">
onChange={(e) => setSearch(e.target.value)} <div className="flex-1 min-w-48">
className="max-w-sm mb-3" <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 className="space-y-1.5 max-h-48 overflow-y-auto"> </div>
{filtered.map((student) => ( <div>
<div key={student.id} className="flex items-center justify-between px-3 py-2 rounded-lg border border-slate-100 bg-slate-50"> <label className="text-xs uppercase tracking-wider mb-1 block" style={{ color: "var(--muted-foreground)" }}>
<div> Срок доступа
<p className="text-sm font-medium text-slate-700">{student.name}</p> </label>
<p className="text-xs text-slate-400">{student.email}</p> <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> </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 && ( {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>
</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> </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>
);
}