Compare commits

..

7 Commits

Author SHA1 Message Date
admins 3b57b14d9b Add file attachments to questions (new question form + admin reply)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:26:17 +05:00
admins 367764b71e Expand /questions/new form: wider container, larger inputs, hint texts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:10:33 +05:00
admins acf7ee49aa Replace single-line reply input with resizable textarea in QuestionSplitView 2026-05-19 18:01:27 +05:00
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
11 changed files with 712 additions and 86 deletions
+113 -15
View File
@@ -1,15 +1,56 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
interface FileAttachment {
name: string;
url: string;
size: number;
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} КБ`;
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
export default function NewQuestionPage() { export default function NewQuestionPage() {
const router = useRouter(); const router = useRouter();
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [text, setText] = useState(""); const [text, setText] = useState("");
const [files, setFiles] = useState<FileAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const selected = Array.from(e.target.files ?? []);
if (!selected.length) return;
setUploading(true);
setError("");
try {
const uploaded: FileAttachment[] = [];
for (const f of selected) {
const form = new FormData();
form.append("file", f);
const res = await fetch("/api/student/question-upload", { method: "POST", body: form });
if (!res.ok) {
const d = await res.json();
throw new Error(d.error ?? "Ошибка загрузки файла");
}
uploaded.push(await res.json());
}
setFiles((prev) => [...prev, ...uploaded]);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -21,7 +62,7 @@ export default function NewQuestionPage() {
const res = await fetch("/api/questions", { const res = await fetch("/api/questions", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, text }), body: JSON.stringify({ title, text, files }),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json();
@@ -37,8 +78,8 @@ export default function NewQuestionPage() {
} }
return ( return (
<div className="max-w-xl mx-auto px-4 py-8"> <div className="max-w-2xl mx-auto px-6 py-10">
<div className="flex items-center gap-3 mb-6"> <div className="mb-8">
<Link <Link
href="/questions" href="/questions"
className="text-sm" className="text-sm"
@@ -48,14 +89,17 @@ export default function NewQuestionPage() {
</Link> </Link>
</div> </div>
<h1 className="text-xl font-bold mb-6" style={{ color: "var(--foreground)" }}> <h1 className="text-2xl font-bold mb-2" style={{ color: "var(--foreground)" }}>
Новый вопрос Новый вопрос
</h1> </h1>
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
Опишите свой вопрос подробно куратор ответит в ближайшее время.
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-6">
<div> <div>
<label <label
className="block text-sm font-bold mb-1" className="block text-sm font-bold mb-2"
style={{ color: "var(--foreground)" }} style={{ color: "var(--foreground)" }}
> >
Тема вопроса Тема вопроса
@@ -66,18 +110,21 @@ export default function NewQuestionPage() {
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Кратко опишите суть вопроса" placeholder="Кратко опишите суть вопроса"
required required
className="w-full text-sm px-3 py-2 outline-none" className="w-full text-sm px-4 py-3 outline-none"
style={{ style={{
border: "2px solid var(--border)", border: "2px solid var(--border)",
background: "var(--color-surface)", background: "var(--color-surface)",
color: "var(--foreground)", color: "var(--foreground)",
}} }}
/> />
<p className="text-xs mt-1.5" style={{ color: "var(--muted-foreground)" }}>
Например: «Не получается настроить плагин» или «Вопрос по уроку 3»
</p>
</div> </div>
<div> <div>
<label <label
className="block text-sm font-bold mb-1" className="block text-sm font-bold mb-2"
style={{ color: "var(--foreground)" }} style={{ color: "var(--foreground)" }}
> >
Описание Описание
@@ -85,37 +132,88 @@ export default function NewQuestionPage() {
<textarea <textarea
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
placeholder="Подробно опишите вопрос или проблему" placeholder="Подробно опишите вопрос или проблему. Что именно не получается? Что уже пробовали? Прикрепите скриншот или ссылку, если нужно."
required required
rows={6} rows={10}
className="w-full text-sm px-3 py-2 outline-none resize-none" className="w-full text-sm px-4 py-3 outline-none resize-y"
style={{ style={{
border: "2px solid var(--border)", border: "2px solid var(--border)",
background: "var(--color-surface)", background: "var(--color-surface)",
color: "var(--foreground)", color: "var(--foreground)",
minHeight: "200px",
}} }}
/> />
</div> </div>
{/* File attachments */}
<div>
<input
ref={fileInputRef}
type="file"
multiple
accept=".jpg,.jpeg,.png,.pdf,.md,.txt"
className="hidden"
onChange={handleFileSelect}
/>
{files.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{files.map((f, i) => (
<div
key={f.url}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5"
style={{ background: "var(--color-surface)", border: "1px solid var(--border)" }}
>
📎 <span>{f.name}</span>
<span style={{ color: "#999" }}>· {formatFileSize(f.size)}</span>
<button
type="button"
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
className="ml-1"
style={{ color: "var(--muted-foreground)" }}
>
×
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="text-xs px-3 py-2"
style={{ background: "var(--color-surface)", border: "1px solid var(--border)" }}
>
{uploading ? "Загрузка..." : "📎 Прикрепить файл"}
</button>
<span className="text-xs" style={{ color: "#999" }}>
jpg, png, pdf, md · до 10 МБ
</span>
</div>
</div>
{error && ( {error && (
<p className="text-sm" style={{ color: "var(--destructive)" }}> <p className="text-sm" style={{ color: "var(--destructive)" }}>
{error} {error}
</p> </p>
)} )}
<div className="flex justify-end">
<button <button
type="submit" type="submit"
disabled={loading || !title.trim() || !text.trim()} disabled={loading || uploading || !title.trim() || !text.trim()}
className="self-end text-sm font-bold px-6 py-2" className="text-sm font-bold px-8 py-3"
style={{ style={{
background: "var(--foreground)", background: "var(--foreground)",
color: "var(--background)", color: "var(--background)",
border: "none", border: "none",
opacity: loading ? 0.6 : 1, opacity: loading || uploading || !title.trim() || !text.trim() ? 0.5 : 1,
}} }}
> >
{loading ? "Отправка..." : "Отправить →"} {loading ? "Отправка..." : "Отправить →"}
</button> </button>
</div>
</form> </form>
</div> </div>
); );
+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 Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
import { CommentsTable } from "@/components/admin/comments-table"; 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: "Комментарии" }; export const metadata = { title: "Комментарии" };
@@ -12,6 +15,9 @@ interface Props {
} }
export default async function AdminCommentsPage({ searchParams }: 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 { page = "1", search = "" } = await searchParams;
const currentPage = Math.max(1, parseInt(page) || 1); const currentPage = Math.max(1, parseInt(page) || 1);
const skip = (currentPage - 1) * PAGE_SIZE; const skip = (currentPage - 1) * PAGE_SIZE;
@@ -106,7 +112,7 @@ export default async function AdminCommentsPage({ searchParams }: Props) {
{currentPage < totalPages && ( {currentPage < totalPages && (
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs"></Link> <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>
)} )}
</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; const PAGE_SIZE = 20;
interface Props { 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) { 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 currentPage = Math.max(1, parseInt(page) || 1);
const skip = (currentPage - 1) * PAGE_SIZE; const skip = (currentPage - 1) * PAGE_SIZE;
@@ -37,6 +37,8 @@ export default async function UsersPage({ searchParams }: Props) {
} }
: {}), : {}),
...(role ? { role } : {}), ...(role ? { role } : {}),
...(emailVerified === "true" ? { emailVerified: true } : {}),
...(emailVerified === "false" ? { emailVerified: false } : {}),
...(balanceUserIds !== null ? { id: { in: balanceUserIds } } : {}), ...(balanceUserIds !== null ? { id: { in: balanceUserIds } } : {}),
}; };
@@ -78,6 +80,7 @@ export default async function UsersPage({ searchParams }: Props) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (search) params.set("search", search); if (search) params.set("search", search);
if (role) params.set("role", role); if (role) params.set("role", role);
if (emailVerified) params.set("emailVerified", emailVerified);
if (balance) params.set("balance", balance); if (balance) params.set("balance", balance);
params.set("page", String(p)); params.set("page", String(p));
return `/admin/users?${params.toString()}`; return `/admin/users?${params.toString()}`;
@@ -101,7 +104,7 @@ export default async function UsersPage({ searchParams }: Props) {
{/* Filters */} {/* Filters */}
<Suspense> <Suspense>
<UsersSearch initialSearch={search} initialRole={role} initialBalance={balance} /> <UsersSearch initialSearch={search} initialRole={role} initialEmailVerified={emailVerified} initialBalance={balance} />
</Suspense> </Suspense>
<UsersTable users={tableUsers} /> <UsersTable users={tableUsers} />
@@ -132,7 +135,7 @@ export default async function UsersPage({ searchParams }: Props) {
{currentPage < totalPages && ( {currentPage < totalPages && (
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs"></Link> <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>
)} )}
</div> </div>
+27 -1
View File
@@ -4,6 +4,18 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { sendQuestionCreatedEmail } from "@/lib/email"; import { sendQuestionCreatedEmail } from "@/lib/email";
interface FileAttachment {
name: string;
url: string;
size: number;
}
function buildS3Prefix(): string {
const endpoint = process.env.S3_ENDPOINT ?? "";
const bucket = process.env.S3_BUCKET ?? "";
return `${endpoint}/${bucket}/`;
}
export async function GET() { export async function GET() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -59,16 +71,29 @@ export async function POST(req: NextRequest) {
} catch { } catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
} }
const { title, text, courseId } = body as { const { title, text, courseId, files } = body as {
title: string; title: string;
text: string; text: string;
courseId?: string; courseId?: string;
files?: FileAttachment[];
}; };
if (!title?.trim() || !text?.trim()) { if (!title?.trim() || !text?.trim()) {
return NextResponse.json({ error: "title and text are required" }, { status: 400 }); return NextResponse.json({ error: "title and text are required" }, { status: 400 });
} }
const s3Prefix = buildS3Prefix();
const safeFiles = files
?.filter(
(f) =>
typeof f.name === "string" &&
typeof f.url === "string" &&
f.url.startsWith("https://") &&
f.url.startsWith(s3Prefix) &&
typeof f.size === "number"
)
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
const question = await prisma.studentQuestion.create({ const question = await prisma.studentQuestion.create({
data: { data: {
userId: session.user.id, userId: session.user.id,
@@ -78,6 +103,7 @@ export async function POST(req: NextRequest) {
create: { create: {
authorId: session.user.id, authorId: session.user.id,
text: text.trim(), text: text.trim(),
files: safeFiles?.length ? (safeFiles as object[]) : undefined,
}, },
}, },
}, },
+80 -29
View File
@@ -1,13 +1,12 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import Link from "next/link"; import Link from "next/link";
import { HomeworkFilters } from "@/components/admin/homework-filters";
import { Suspense } from "react"; import { Suspense } from "react";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
interface Props { interface Props {
searchParams: Promise<{ searchParams: Promise<{
search?: string; q?: string;
status?: string; status?: string;
courseId?: string; courseId?: string;
page?: string; page?: string;
@@ -15,20 +14,22 @@ interface Props {
} }
export default async function HomeworkListPage({ searchParams }: Props) { export default async function HomeworkListPage({ searchParams }: Props) {
const { search = "", status = "", courseId = "", page = "1" } = await searchParams; const sp = await searchParams;
const currentPage = Math.max(1, parseInt(page) || 1); 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; const skip = (currentPage - 1) * PAGE_SIZE;
// Build where clause // Build where clause
const where = { const where = {
...(search ...(q
? { ? {
user: {
OR: [ OR: [
{ name: { contains: search, mode: "insensitive" as const } }, { user: { name: { contains: q, mode: "insensitive" as const } } },
{ email: { contains: search, mode: "insensitive" as const } }, { user: { email: { contains: q, mode: "insensitive" as const } } },
{ homework: { lesson: { title: { contains: q, mode: "insensitive" as const } } } },
], ],
},
} }
: {}), : {}),
...(courseId ...(courseId
@@ -38,10 +39,8 @@ export default async function HomeworkListPage({ searchParams }: Props) {
}, },
} }
: {}), : {}),
...(status === "pending" ? { status: "PENDING" } : {}), ...(status === "pending" ? { feedbacks: { none: {} } } : {}),
...(status === "reviewing" ? { status: "REVIEWING" } : {}), ...(status === "reviewed" ? { feedbacks: { some: {} } } : {}),
...(status === "approved" ? { status: "APPROVED" } : {}),
...(status === "rejected" ? { status: "REJECTED" } : {}),
}; };
const [submissions, total, courses] = await Promise.all([ 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 totalPages = Math.ceil(total / PAGE_SIZE);
const pendingCount = submissions.filter((s) => s.status === "PENDING").length;
function pageUrl(p: number) { function pageUrl(p: number) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (search) params.set("search", search); if (q) params.set("q", q);
if (status) params.set("status", status); if (status) params.set("status", status);
if (courseId) params.set("courseId", courseId); if (courseId) params.set("courseId", courseId);
params.set("page", String(p)); params.set("page", String(p));
return `/curator/homework?${params.toString()}`; 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 ( return (
<div className="p-8 max-w-3xl"> <div className="p-8 max-w-3xl">
<div className="mb-6"> <div className="mb-6">
@@ -92,12 +99,59 @@ export default async function HomeworkListPage({ searchParams }: Props) {
Домашние задания Домашние задания
</h1> </h1>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}> <p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
{total} {total === 1 ? "работа" : "работ"} · {pendingCount} ожидают проверки {total} {total === 1 ? "работа" : "работ"}
</p> </p>
</div> </div>
{/* Filters */}
<Suspense> <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> </Suspense>
{submissions.length === 0 ? ( {submissions.length === 0 ? (
@@ -111,19 +165,16 @@ export default async function HomeworkListPage({ searchParams }: Props) {
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
{submissions.map((s) => { {submissions.map((s) => {
const statusMap: Record<string, { label: string; bg: string; color: string; border: string }> = { const hasReview = s.feedbacks.length > 0;
PENDING: { label: "Новое", bg: "var(--foreground)", color: "var(--background)", border: "var(--foreground)" }, const reviewBadge = hasReview
REVIEWING: { label: "На рассмотрении", bg: "oklch(0.9 0.08 80)", color: "oklch(0.4 0.1 80)", border: "oklch(0.75 0.1 80)" }, ? { label: "С отзывом", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)" }
APPROVED: { label: "Одобрено", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)", border: "oklch(0.7 0.15 145)" }, : { label: "Без ответа", bg: "var(--foreground)", color: "var(--background)" };
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;
return ( return (
<Link <Link
key={s.id} key={s.id}
href={`/curator/homework/${s.id}`} href={`/curator/homework/${s.id}`}
className="flex items-center gap-4 px-4 py-3 text-sm transition-opacity hover:opacity-80" 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"> <div className="flex-1 min-w-0">
<p className="font-medium truncate">{s.user.name}</p> <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"> <div className="text-right shrink-0">
<span <span
className="text-xs px-2 py-0.5 font-medium" 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> </span>
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}> <p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
{new Date(s.submittedAt).toLocaleDateString("ru-RU")} {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> <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)" }}> <span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>
стр. {currentPage} из {totalPages} Страница {currentPage} из {totalPages} · Всего: {total}
</span> </span>
</div> </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({ export function UsersSearch({
initialSearch, initialSearch,
initialRole, initialRole,
initialEmailVerified,
initialBalance, initialBalance,
}: { }: {
initialSearch: string; initialSearch: string;
initialRole: string; initialRole: string;
initialEmailVerified: string;
initialBalance: string; initialBalance: string;
}) { }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const [, startTransition] = useTransition(); 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(); const params = new URLSearchParams();
if (search) params.set("search", search); if (search) params.set("search", search);
if (role) params.set("role", role); if (role) params.set("role", role);
if (emailVerified) params.set("emailVerified", emailVerified);
if (balance) params.set("balance", balance); if (balance) params.set("balance", balance);
startTransition(() => router.push(`${pathname}?${params.toString()}`)); startTransition(() => router.push(`${pathname}?${params.toString()}`));
} }
@@ -45,7 +48,7 @@ export function UsersSearch({
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")} onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => { onBlur={(e) => {
e.currentTarget.style.borderColor = "var(--border)"; 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(); }} onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }}
/> />
@@ -53,7 +56,7 @@ export function UsersSearch({
<select <select
defaultValue={initialRole} 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" }} style={{ ...inputStyle, appearance: "none", cursor: "pointer" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")} onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")} onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
@@ -64,9 +67,21 @@ export function UsersSearch({
<option value="admin">Администраторы</option> <option value="admin">Администраторы</option>
</select> </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 <button
type="button" type="button"
onClick={() => update(initialSearch, initialRole, initialBalance === "nonzero" ? "" : "nonzero")} onClick={() => update(initialSearch, initialRole, initialEmailVerified, initialBalance === "nonzero" ? "" : "nonzero")}
className="text-xs px-3" className="text-xs px-3"
style={{ style={{
border: "2px solid var(--border)", border: "2px solid var(--border)",
@@ -78,7 +93,7 @@ export function UsersSearch({
С балансом С балансом
</button> </button>
{(initialSearch || initialRole || initialBalance) && ( {(initialSearch || initialRole || initialEmailVerified || initialBalance) && (
<button <button
type="button" type="button"
onClick={() => startTransition(() => router.push(pathname))} onClick={() => startTransition(() => router.push(pathname))}
+2
View File
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { QuickEnrollButton } from "@/components/admin/quick-enroll-modal";
type Enrollment = { type Enrollment = {
courseId: string; courseId: string;
@@ -175,6 +176,7 @@ export function UsersTable({ users }: { users: UserRow[] }) {
<td className="px-3 py-3 relative"> <td className="px-3 py-3 relative">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{user.role !== "admin" && <ImpersonateButton userId={user.id} />} {user.role !== "admin" && <ImpersonateButton userId={user.id} />}
<QuickEnrollButton userId={user.id} userName={user.name ?? user.email} />
<div <div
className="relative inline-block" className="relative inline-block"
onMouseEnter={() => setHoveredId(user.id)} onMouseEnter={() => setHoveredId(user.id)}
+88 -9
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
interface FileAttachment { interface FileAttachment {
name: string; name: string;
@@ -58,11 +58,14 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
const [detail, setDetail] = useState<QuestionDetail | null>(null); const [detail, setDetail] = useState<QuestionDetail | null>(null);
const [tab, setTab] = useState<"open" | "closed">("open"); const [tab, setTab] = useState<"open" | "closed">("open");
const [replyText, setReplyText] = useState(""); const [replyText, setReplyText] = useState("");
const [replyFiles, setReplyFiles] = useState<FileAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [listError, setListError] = useState(""); const [listError, setListError] = useState("");
const [listLoading, setListLoading] = useState(true); const [listLoading, setListLoading] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const fetchList = useCallback(async () => { const fetchList = useCallback(async () => {
setListError(""); setListError("");
@@ -86,9 +89,36 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
fetchList(); fetchList();
}, [fetchList]); }, [fetchList]);
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const selected = Array.from(e.target.files ?? []);
if (!selected.length) return;
setUploading(true);
setError("");
try {
const uploaded: FileAttachment[] = [];
for (const f of selected) {
const form = new FormData();
form.append("file", f);
const res = await fetch("/api/student/question-upload", { method: "POST", body: form });
if (!res.ok) {
const d = await res.json();
throw new Error(d.error ?? "Ошибка загрузки файла");
}
uploaded.push(await res.json());
}
setReplyFiles((prev) => [...prev, ...uploaded]);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function selectQuestion(id: string) { async function selectQuestion(id: string) {
setSelectedId(id); setSelectedId(id);
setReplyText(""); setReplyText("");
setReplyFiles([]);
setError(""); setError("");
const res = await fetch(`/api/questions/${id}`); const res = await fetch(`/api/questions/${id}`);
if (res.ok) { if (res.ok) {
@@ -105,14 +135,16 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
} }
async function handleReply() { async function handleReply() {
if (!replyText.trim() || !selectedId) return; if (uploading) return;
if (!replyText.trim() && replyFiles.length === 0) return;
if (!selectedId) return;
setSending(true); setSending(true);
setError(""); setError("");
try { try {
const res = await fetch(`/api/questions/${selectedId}/messages`, { const res = await fetch(`/api/questions/${selectedId}/messages`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: replyText.trim() }), body: JSON.stringify({ text: replyText.trim(), files: replyFiles }),
}); });
if (!res.ok) throw new Error("Ошибка отправки"); if (!res.ok) throw new Error("Ошибка отправки");
const msg = await res.json(); const msg = await res.json();
@@ -120,6 +152,7 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
prev ? { ...prev, messages: [...prev.messages, msg] } : null prev ? { ...prev, messages: [...prev.messages, msg] } : null
); );
setReplyText(""); setReplyText("");
setReplyFiles([]);
fetchList(); fetchList();
} catch { } catch {
setError("Не удалось отправить ответ"); setError("Не удалось отправить ответ");
@@ -323,11 +356,18 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
style={{ borderTop: "1px solid var(--border)" }} style={{ borderTop: "1px solid var(--border)" }}
> >
<div <div
className="flex gap-2" className="flex flex-col gap-2"
style={{ opacity: detail.status === "CLOSED" ? 0.5 : 1 }} style={{ opacity: detail.status === "CLOSED" ? 0.5 : 1 }}
> >
<input <input
type="text" ref={fileInputRef}
type="file"
multiple
accept=".jpg,.jpeg,.png,.pdf,.md,.txt"
className="hidden"
onChange={handleFileSelect}
/>
<textarea
value={replyText} value={replyText}
onChange={(e) => setReplyText(e.target.value)} onChange={(e) => setReplyText(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -340,27 +380,66 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
placeholder={ placeholder={
detail.status === "CLOSED" ? "Вопрос закрыт" : "Написать ответ..." detail.status === "CLOSED" ? "Вопрос закрыт" : "Написать ответ..."
} }
className="flex-1 text-sm px-3 py-2 outline-none" rows={4}
className="w-full text-sm px-3 py-2 outline-none resize-y"
style={{ style={{
border: "1px solid var(--border)", border: "1px solid var(--border)",
background: "var(--background)", background: "var(--background)",
color: "var(--foreground)", color: "var(--foreground)",
}} }}
/> />
{replyFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{replyFiles.map((f, i) => (
<div
key={f.url}
className="flex items-center gap-1 text-xs px-2 py-1"
style={{ background: "var(--muted)", border: "1px solid var(--border)" }}
>
📎 {f.name}
<span style={{ color: "#999" }}>· {formatFileSize(f.size)}</span>
<button
type="button"
onClick={() => setReplyFiles((prev) => prev.filter((_, j) => j !== i))}
className="ml-1"
style={{ color: "var(--muted-foreground)" }}
>
×
</button>
</div>
))}
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading || detail.status === "CLOSED"}
className="text-xs px-2 py-1"
style={{ background: "var(--muted)", border: "1px solid var(--border)" }}
>
{uploading ? "Загрузка..." : "📎 Прикрепить"}
</button>
<span className="text-xs" style={{ color: "#999" }}>
jpg, png, pdf · 10 МБ
</span>
</div>
<button <button
onClick={handleReply} onClick={handleReply}
disabled={sending || !replyText.trim() || detail.status === "CLOSED"} disabled={sending || uploading || ((!replyText.trim()) && replyFiles.length === 0) || detail.status === "CLOSED"}
className="text-xs font-bold px-4 py-2" className="text-xs font-bold px-4 py-2"
style={{ style={{
background: "var(--foreground)", background: "var(--foreground)",
color: "var(--background)", color: "var(--background)",
border: "none", border: "none",
opacity: sending ? 0.6 : 1, opacity: sending || uploading || ((!replyText.trim()) && replyFiles.length === 0) ? 0.5 : 1,
}} }}
> >
{sending ? "..." : "Отправить"} {sending ? "..." : "Отправить"}
</button> </button>
</div> </div>
</div>
{error && ( {error && (
<p className="text-xs mt-1" style={{ color: "var(--destructive)" }}> <p className="text-xs mt-1" style={{ color: "var(--destructive)" }}>
{error} {error}