-
{session.user.name}
-
+
+
Обзор
+
Панель куратора
+
+
+ 0} />
+
+
+
+
+ {pending > 0 ? (
+
+ Перейти к проверке ({pending}) →
+
+ ) : (
+
+
✓
+
Все работы проверены
+
Новых заданий нет
-
-
-
- Панель куратора
-
- Здесь будут домашние задания на проверку.
-
-
-
📝
-
Домашние задания
-
Новых заданий нет
-
-
-
👥
-
Мои ученики
-
Откроется в Этапе 3
-
-
-
+ )}
+
+ );
+}
+
+function StatCard({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
+ return (
+
);
}
diff --git a/src/app/curator/homework/[submissionId]/actions.ts b/src/app/curator/homework/[submissionId]/actions.ts
new file mode 100644
index 0000000..078d518
--- /dev/null
+++ b/src/app/curator/homework/[submissionId]/actions.ts
@@ -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}`);
+}
diff --git a/src/app/curator/homework/[submissionId]/feedback-form.tsx b/src/app/curator/homework/[submissionId]/feedback-form.tsx
new file mode 100644
index 0000000..a0f6042
--- /dev/null
+++ b/src/app/curator/homework/[submissionId]/feedback-form.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/app/curator/homework/[submissionId]/page.tsx b/src/app/curator/homework/[submissionId]/page.tsx
new file mode 100644
index 0000000..132805d
--- /dev/null
+++ b/src/app/curator/homework/[submissionId]/page.tsx
@@ -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 (
+
+
+
+ {/* Meta */}
+
+
{submission.homework.lesson.title}
+
+ {submission.homework.lesson.module.course.title} · {submission.homework.lesson.module.title}
+
+
+
+ {/* Student info */}
+
+
+
{submission.user.name}
+
{submission.user.email}
+
+
+ Сдано {new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
+
+
+
+ {/* Homework description */}
+
+
Задание
+
+ {submission.homework.description}
+
+
+
+ {/* Student answer */}
+
+
Ответ студента
+ {submission.text ? (
+
+ {submission.text}
+
+ ) : (
+
Текст не добавлен
+ )}
+
+
+ {/* Files */}
+ {files.length > 0 && (
+
+
Прикреплённые файлы
+
+
+ )}
+
+ {/* Existing feedback */}
+ {submission.feedbacks.map((fb) => (
+
+
+
Фидбек
+
+ {fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
+
+
+
{fb.text}
+
+ ))}
+
+ {/* Feedback form */}
+ {!isReviewed &&
}
+
+ );
+}
diff --git a/src/app/curator/homework/page.tsx b/src/app/curator/homework/page.tsx
new file mode 100644
index 0000000..b852d94
--- /dev/null
+++ b/src/app/curator/homework/page.tsx
@@ -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 (
+
+
Домашние задания
+
+ {pending.length} ожидают проверки · {reviewed.length} проверено
+
+
+ {pending.length > 0 && (
+
+
+ Ожидают проверки
+
+
+ {pending.map((s) => (
+
+ ))}
+
+
+ )}
+
+ {reviewed.length > 0 && (
+
+
+ Проверено
+
+
+ {reviewed.map((s) => (
+
+ ))}
+
+
+ )}
+
+ {submissions.length === 0 && (
+
+ )}
+
+ );
+}
+
+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 (
+
+
+
{submission.user.name}
+
+ {submission.homework.lesson.module.course.title} · {submission.homework.lesson.title}
+
+
+
+
+ {pending ? "Новое" : "Проверено"}
+
+
+ {new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
+
+
+
+ );
+}
diff --git a/src/app/curator/layout.tsx b/src/app/curator/layout.tsx
new file mode 100644
index 0000000..c01e11f
--- /dev/null
+++ b/src/app/curator/layout.tsx
@@ -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 (
+
+ {/* Sidebar */}
+
+
+ {/* Content */}
+
+ {children}
+
+
+ );
+}
+
+function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/admin/homework-editor.tsx b/src/components/admin/homework-editor.tsx
new file mode 100644
index 0000000..f6d6663
--- /dev/null
+++ b/src/components/admin/homework-editor.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { saveHomework, deleteHomework } from "@/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/homework-actions";
+
+interface Props {
+ lessonId: string;
+ initial: { id: string; description: string } | null;
+}
+
+export function HomeworkEditor({ lessonId, initial }: Props) {
+ const [editing, setEditing] = useState(!initial);
+ const [text, setText] = useState(initial?.description ?? "");
+ const [saved, setSaved] = useState(false);
+ const [pending, startTransition] = useTransition();
+
+ const inputStyle = {
+ border: "2px solid var(--border)",
+ background: "var(--background)",
+ outline: "none",
+ width: "100%",
+ padding: "0.5rem 0.75rem",
+ fontSize: "0.875rem",
+ fontFamily: "inherit",
+ resize: "vertical" as const,
+ minHeight: "120px",
+ };
+
+ function handleSave() {
+ if (!text.trim()) return;
+ startTransition(async () => {
+ await saveHomework(lessonId, text.trim());
+ setSaved(true);
+ setEditing(false);
+ setTimeout(() => setSaved(false), 2000);
+ });
+ }
+
+ function handleDelete() {
+ if (!confirm("Удалить домашнее задание? Все сданные работы будут удалены.")) return;
+ startTransition(async () => {
+ await deleteHomework(lessonId);
+ setText("");
+ setEditing(true);
+ });
+ }
+
+ if (!editing && initial) {
+ return (
+
+
+ {text || initial.description}
+
+
+
+
+ {saved && ✓ Сохранено}
+
+
+ );
+ }
+
+ return (
+
+
+ );
+}
diff --git a/src/components/student/homework-section.tsx b/src/components/student/homework-section.tsx
new file mode 100644
index 0000000..8822c87
--- /dev/null
+++ b/src/components/student/homework-section.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { submitHomework } from "@/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions";
+
+interface HWFile { name: string; url: string; size: number }
+
+interface Feedback {
+ id: string;
+ text: string;
+ createdAt: Date;
+ curator: { name: string };
+}
+
+interface Submission {
+ id: string;
+ text: string | null;
+ files: HWFile[];
+ submittedAt: Date;
+ feedbacks: Feedback[];
+}
+
+interface Props {
+ homework: { id: string; description: string };
+ submission: Submission | null;
+ slug: string;
+ lessonId: 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 function HomeworkSection({ homework, submission, slug, lessonId }: Props) {
+ const [text, setText] = useState(submission?.text ?? "");
+ const [files, setFiles] = useState
(submission?.files ?? []);
+ const [uploading, setUploading] = useState(false);
+ const [pending, startTransition] = useTransition();
+ const [editing, setEditing] = useState(!submission);
+
+ const isReviewed = submission && submission.feedbacks.length > 0;
+
+ const inputStyle = {
+ border: "2px solid var(--border)",
+ background: "var(--background)",
+ outline: "none",
+ width: "100%",
+ padding: "0.5rem 0.75rem",
+ fontSize: "0.875rem",
+ fontFamily: "inherit",
+ resize: "vertical" as const,
+ minHeight: "140px",
+ };
+
+ async function handleFileUpload(e: React.ChangeEvent) {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setUploading(true);
+ const fd = new FormData();
+ fd.append("file", file);
+ const res = await fetch("/api/student/homework-upload", { method: "POST", body: fd });
+ const data = await res.json();
+ if (data.url) setFiles((prev) => [...prev, data]);
+ setUploading(false);
+ e.target.value = "";
+ }
+
+ function removeFile(url: string) {
+ setFiles((prev) => prev.filter((f) => f.url !== url));
+ }
+
+ function handleSubmit() {
+ startTransition(() => submitHomework(homework.id, slug, lessonId, text, files));
+ setEditing(false);
+ }
+
+ return (
+
+ {/* Assignment description */}
+
+ {homework.description}
+
+
+ {/* Submitted & reviewed */}
+ {isReviewed && (
+
+
+
Ваш ответ
+
+ {submission!.text || "—"}
+
+
+ {submission!.feedbacks.map((fb) => (
+
+
+
Обратная связь
+
+ {fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
+
+
+
{fb.text}
+
+ ))}
+
+ )}
+
+ {/* Submitted, pending review */}
+ {submission && !isReviewed && !editing && (
+
+
+
+
+ На проверке
+
+
+ Сдано {new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
+
+
+
+
+ {submission.text && (
+
+ {submission.text}
+
+ )}
+ {submission.files.length > 0 && (
+
+ )}
+
+ )}
+
+ {/* Form */}
+ {editing && !isReviewed && (
+
+
+ )}
+
+ );
+}