Add homework system (admin, student, curator)

Admin:
- HomeworkEditor in lesson page: create/update/delete assignment description

Student:
- HomeworkSection in lesson page: view assignment, submit text + files
- Resubmission allowed until curator gives feedback
- Shows feedback from curator with date and name

Curator:
- New layout with Second Brain dark sidebar (replaces green theme)
- /curator/dashboard: stats cards (pending, total, reviewed this week)
- /curator/homework: list of all submissions, pending highlighted
- /curator/homework/[id]: review submission, write feedback, redirect after send

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 14:13:24 +05:00
parent d0c8c6dd53
commit 543d5b2d5e
13 changed files with 836 additions and 31 deletions
+44 -30
View File
@@ -1,43 +1,57 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { LogoutButton } from "@/components/layout/logout-button";
import { prisma } from "@/lib/prisma";
import Link from "next/link";
export default async function CuratorDashboard() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
if (session.user.role !== "curator" && session.user.role !== "admin") {
redirect("/dashboard");
}
const [pending, total, recentFeedbacks] = await Promise.all([
prisma.homeworkSubmission.count({ where: { feedbacks: { none: {} } } }),
prisma.homeworkSubmission.count(),
prisma.homeworkFeedback.count({
where: {
createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
curatorId: session.user.id,
},
}),
]);
return (
<div className="min-h-screen bg-green-50">
<header className="bg-white border-b border-green-100 px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-green-900">Second Brain Куратор</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{session.user.name}</span>
<LogoutButton />
<div className="p-8 max-w-3xl">
<h1 className="text-2xl font-bold mb-1">Обзор</h1>
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>Панель куратора</p>
<div className="grid grid-cols-3 gap-4 mb-8">
<StatCard label="Ожидают проверки" value={pending} accent={pending > 0} />
<StatCard label="Всего сдано" value={total} />
<StatCard label="Проверено за 7 дней" value={recentFeedbacks} />
</div>
{pending > 0 ? (
<Link href="/curator/homework" className="btn-aubade btn-aubade-accent inline-flex items-center gap-2 px-5 py-2.5 text-sm">
Перейти к проверке ({pending})
</Link>
) : (
<div className="card-aubade p-8 text-center">
<p className="text-3xl mb-2"></p>
<p className="font-bold">Все работы проверены</p>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>Новых заданий нет</p>
</div>
</header>
<main className="max-w-4xl mx-auto px-6 py-10">
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
Панель куратора
</h2>
<p className="text-gray-500 mb-8">Здесь будут домашние задания на проверку.</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white rounded-2xl border border-green-100 p-6">
<p className="text-3xl mb-2">📝</p>
<p className="font-medium text-gray-800">Домашние задания</p>
<p className="text-sm text-gray-400 mt-1">Новых заданий нет</p>
</div>
<div className="bg-white rounded-2xl border border-green-100 p-6">
<p className="text-3xl mb-2">👥</p>
<p className="font-medium text-gray-800">Мои ученики</p>
<p className="text-sm text-gray-400 mt-1">Откроется в Этапе 3</p>
</div>
</div>
</main>
)}
</div>
);
}
function StatCard({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
return (
<div className="card-aubade p-4">
<p className="text-3xl font-bold" style={{ color: accent ? "oklch(0.577 0.245 27.325)" : "var(--foreground)" }}>
{value}
</p>
<p className="text-xs mt-1 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>{label}</p>
</div>
);
}
@@ -0,0 +1,20 @@
"use server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
export async function submitFeedback(submissionId: string, text: string) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) {
throw new Error("Forbidden");
}
await prisma.homeworkFeedback.create({
data: { submissionId, curatorId: session.user.id, text },
});
revalidatePath("/curator/homework");
revalidatePath(`/curator/homework/${submissionId}`);
}
@@ -0,0 +1,55 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { submitFeedback } from "./actions";
export function FeedbackForm({ submissionId }: { submissionId: string }) {
const [text, setText] = useState("");
const [pending, startTransition] = useTransition();
const router = useRouter();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!text.trim()) return;
startTransition(async () => {
await submitFeedback(submissionId, text.trim());
router.push("/curator/homework");
});
}
return (
<form onSubmit={handleSubmit} className="space-y-3">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Написать фидбек
</p>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
required
placeholder="Напишите обратную связь студенту..."
style={{
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "0.875rem",
fontFamily: "inherit",
resize: "vertical",
minHeight: "120px",
}}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
<button
type="submit"
disabled={pending || !text.trim()}
className="btn-aubade btn-aubade-accent px-5 py-2 text-sm"
style={{ opacity: pending || !text.trim() ? 0.6 : 1 }}
>
{pending ? "Отправка..." : "Отправить фидбек"}
</button>
</form>
);
}
@@ -0,0 +1,132 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { FeedbackForm } from "./feedback-form";
interface Props {
params: Promise<{ submissionId: string }>;
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
export default async function SubmissionPage({ params }: Props) {
const { submissionId } = await params;
const submission = await prisma.homeworkSubmission.findUnique({
where: { id: submissionId },
include: {
user: { select: { name: true, email: true } },
feedbacks: {
include: { curator: { select: { name: true } } },
orderBy: { createdAt: "desc" },
},
homework: {
include: {
lesson: {
select: {
title: true,
module: { select: { title: true, course: { select: { title: true } } } },
},
},
},
},
},
});
if (!submission) notFound();
const files = (submission.files as { name: string; url: string; size: number }[]) ?? [];
const isReviewed = submission.feedbacks.length > 0;
return (
<div className="p-8 max-w-2xl">
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/curator/homework" className="hover:underline">ДЗ на проверку</Link>
<span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>{submission.user.name}</span>
</nav>
{/* Meta */}
<div className="mb-6">
<h1 className="text-xl font-bold">{submission.homework.lesson.title}</h1>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
{submission.homework.lesson.module.course.title} · {submission.homework.lesson.module.title}
</p>
</div>
{/* Student info */}
<div className="flex items-center justify-between px-4 py-3 mb-6" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
<div>
<p className="font-medium text-sm">{submission.user.name}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{submission.user.email}</p>
</div>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Сдано {new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
</p>
</div>
{/* Homework description */}
<div className="mb-4">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Задание</p>
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
{submission.homework.description}
</div>
</div>
{/* Student answer */}
<div className="mb-4">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Ответ студента</p>
{submission.text ? (
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)" }}>
{submission.text}
</div>
) : (
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>Текст не добавлен</p>
)}
</div>
{/* Files */}
{files.length > 0 && (
<div className="mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Прикреплённые файлы</p>
<div className="space-y-1">
{files.map((f) => (
<a
key={f.url}
href={f.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 px-3 py-2 text-sm"
style={{ border: "2px solid var(--border)" }}
>
<span>📎</span>
<span className="flex-1 underline">{f.name}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
</a>
))}
</div>
</div>
)}
{/* Existing feedback */}
{submission.feedbacks.map((fb) => (
<div key={fb.id} className="mb-4 px-4 py-3" style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}>
<div className="flex items-center justify-between mb-1">
<p className="text-xs font-bold uppercase tracking-widest">Фидбек</p>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
</span>
</div>
<p className="text-sm whitespace-pre-wrap">{fb.text}</p>
</div>
))}
{/* Feedback form */}
{!isReviewed && <FeedbackForm submissionId={submissionId} />}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { prisma } from "@/lib/prisma";
import Link from "next/link";
export default async function HomeworkListPage() {
const submissions = await prisma.homeworkSubmission.findMany({
orderBy: { submittedAt: "desc" },
include: {
user: { select: { name: true, email: true } },
feedbacks: { select: { id: true } },
homework: {
include: {
lesson: {
select: {
title: true,
module: { select: { title: true, course: { select: { title: true } } } },
},
},
},
},
},
});
const pending = submissions.filter((s) => s.feedbacks.length === 0);
const reviewed = submissions.filter((s) => s.feedbacks.length > 0);
return (
<div className="p-8 max-w-3xl">
<h1 className="text-2xl font-bold mb-1">Домашние задания</h1>
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
{pending.length} ожидают проверки · {reviewed.length} проверено
</p>
{pending.length > 0 && (
<div className="mb-8">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Ожидают проверки
</p>
<div className="space-y-2">
{pending.map((s) => (
<SubmissionRow key={s.id} submission={s} pending />
))}
</div>
</div>
)}
{reviewed.length > 0 && (
<div>
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Проверено
</p>
<div className="space-y-2">
{reviewed.map((s) => (
<SubmissionRow key={s.id} submission={s} pending={false} />
))}
</div>
</div>
)}
{submissions.length === 0 && (
<div className="card-aubade p-10 text-center">
<p className="text-3xl mb-2">📭</p>
<p className="font-bold">Работ пока нет</p>
</div>
)}
</div>
);
}
function SubmissionRow({
submission,
pending,
}: {
submission: {
id: string;
submittedAt: Date;
user: { name: string; email: string };
homework: {
lesson: {
title: string;
module: { title: string; course: { title: string } };
};
};
};
pending: boolean;
}) {
return (
<Link
href={`/curator/homework/${submission.id}`}
className="flex items-center gap-4 px-4 py-3 text-sm transition-colors"
style={{ border: `2px solid ${pending ? "var(--foreground)" : "var(--border)"}`, display: "flex" }}
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{submission.user.name}</p>
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>
{submission.homework.lesson.module.course.title} · {submission.homework.lesson.title}
</p>
</div>
<div className="text-right shrink-0">
<span
className="text-xs px-2 py-0.5"
style={{
border: "1px solid var(--border)",
background: pending ? "var(--foreground)" : "transparent",
color: pending ? "var(--background)" : "var(--muted-foreground)",
}}
>
{pending ? "Новое" : "Проверено"}
</span>
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
{new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
</p>
</div>
</Link>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import Link from "next/link";
import { LogoutButton } from "@/components/layout/logout-button";
export default async function CuratorLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
if (session.user.role !== "curator" && session.user.role !== "admin") redirect("/dashboard");
return (
<div className="min-h-screen flex" style={{ backgroundColor: "var(--background)" }}>
{/* Sidebar */}
<aside className="w-52 shrink-0 flex flex-col min-h-screen" style={{ backgroundColor: "var(--sidebar-bg)" }}>
<div className="px-5 py-5" style={{ borderBottom: "1px solid rgba(255,255,255,0.08)" }}>
<p className="text-sm font-bold tracking-wide" style={{ color: "#F5F5F0" }}>Second Brain</p>
<p className="text-xs mt-0.5 uppercase tracking-widest" style={{ color: "#888" }}>Куратор</p>
</div>
<nav className="flex-1 py-3 space-y-0.5 px-2">
<NavLink href="/curator/dashboard">Обзор</NavLink>
<NavLink href="/curator/homework">ДЗ на проверку</NavLink>
</nav>
<div className="px-4 py-4" style={{ borderTop: "1px solid rgba(255,255,255,0.08)" }}>
<p className="text-xs mb-2 truncate" style={{ color: "#888" }}>{session.user.name}</p>
<LogoutButton />
</div>
</aside>
{/* Content */}
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
);
}
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<Link
href={href}
className="block px-3 py-2 text-sm rounded-sm transition-colors"
style={{ color: "#CCCCCC" }}
>
{children}
</Link>
);
}