diff --git a/src/app/(student)/questions/[id]/page.tsx b/src/app/(student)/questions/[id]/page.tsx
new file mode 100644
index 0000000..ba5cb55
--- /dev/null
+++ b/src/app/(student)/questions/[id]/page.tsx
@@ -0,0 +1,86 @@
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth";
+import { redirect, notFound } from "next/navigation";
+import { prisma } from "@/lib/prisma";
+import Link from "next/link";
+import { QuestionThread } from "@/components/questions/QuestionThread";
+
+export default async function QuestionThreadPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session) redirect("/login");
+
+ const { id } = await params;
+
+ const question = await prisma.studentQuestion.findUnique({
+ where: { id },
+ include: {
+ user: { select: { id: true, name: true } },
+ messages: {
+ include: { author: { select: { id: true, name: true, role: true } } },
+ orderBy: { createdAt: "asc" },
+ },
+ },
+ });
+
+ if (!question || question.userId !== session.user.id) notFound();
+
+ // Mark staff messages as read
+ await prisma.studentQuestionMessage.updateMany({
+ where: {
+ questionId: id,
+ isRead: false,
+ NOT: { authorId: session.user.id },
+ },
+ data: { isRead: true },
+ });
+
+ return (
+
+
+
+
+ {question.title}
+
+
+ Создан{" "}
+ {new Date(question.createdAt).toLocaleDateString("ru", {
+ day: "numeric",
+ month: "long",
+ })}{" "}
+ ·{" "}
+
+ {question.status === "OPEN" ? "● Открыт" : "✓ Закрыт"}
+
+
+
+
+ ← Все вопросы
+
+
+
+
({
+ ...m,
+ files: m.files as Array<{ name: string; url: string; size: number }> | null,
+ createdAt: m.createdAt.toISOString(),
+ }))}
+ />
+
+ );
+}
diff --git a/src/app/(student)/questions/new/page.tsx b/src/app/(student)/questions/new/page.tsx
new file mode 100644
index 0000000..3e9c45f
--- /dev/null
+++ b/src/app/(student)/questions/new/page.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+export default function NewQuestionPage() {
+ const router = useRouter();
+ const [title, setTitle] = useState("");
+ const [text, setText] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!title.trim() || !text.trim()) return;
+ setLoading(true);
+ setError("");
+
+ try {
+ const res = await fetch("/api/questions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title, text }),
+ });
+ if (!res.ok) {
+ const data = await res.json();
+ throw new Error(data.error ?? "Ошибка при создании вопроса");
+ }
+ const q = await res.json();
+ router.push(`/questions/${q.id}`);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Ошибка");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+
+ Новый вопрос
+
+
+
+
+ );
+}
diff --git a/src/app/(student)/questions/page.tsx b/src/app/(student)/questions/page.tsx
new file mode 100644
index 0000000..c18d374
--- /dev/null
+++ b/src/app/(student)/questions/page.tsx
@@ -0,0 +1,113 @@
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth";
+import { redirect } from "next/navigation";
+import { prisma } from "@/lib/prisma";
+import Link from "next/link";
+
+export default async function QuestionsPage() {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session) redirect("/login");
+
+ const questions = await prisma.studentQuestion.findMany({
+ where: { userId: session.user.id },
+ include: {
+ _count: {
+ select: {
+ messages: {
+ where: { isRead: false, NOT: { authorId: session.user.id } },
+ },
+ },
+ },
+ messages: {
+ orderBy: { createdAt: "desc" },
+ take: 1,
+ include: { author: { select: { name: true } } },
+ },
+ },
+ orderBy: { updatedAt: "desc" },
+ });
+
+ return (
+
+
+
+ Мои вопросы
+
+
+ + Задать вопрос
+
+
+
+ {questions.length === 0 && (
+
+ У вас ещё нет вопросов.
+
+ )}
+
+
+ {questions.map((q) => {
+ const unread = q._count.messages > 0;
+ const lastMsg = q.messages[0];
+ return (
+
+
+
+ {q.title}
+
+
+ {q.status === "OPEN" ? "● ОТКРЫТ" : "✓ ЗАКРЫТ"}
+
+
+ {unread && (
+
+
+
+ Новый ответ от школы
+
+
+ )}
+ {lastMsg && !unread && (
+
+ последнее от {lastMsg.author.name}
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/questions/QuestionThread.tsx b/src/components/questions/QuestionThread.tsx
new file mode 100644
index 0000000..ab91846
--- /dev/null
+++ b/src/components/questions/QuestionThread.tsx
@@ -0,0 +1,255 @@
+"use client";
+
+import { useState, useRef } from "react";
+
+interface FileAttachment {
+ name: string;
+ url: string;
+ size: number;
+}
+
+interface Message {
+ id: string;
+ authorId: string;
+ text: string;
+ files: FileAttachment[] | null;
+ isRead: boolean;
+ createdAt: string;
+ author: { id: string; name: string; role: string };
+}
+
+interface QuestionThreadProps {
+ questionId: string;
+ questionStatus: "OPEN" | "CLOSED";
+ currentUserId: string;
+ initialMessages: Message[];
+}
+
+function formatDate(iso: string) {
+ return new Date(iso).toLocaleString("ru", {
+ day: "numeric",
+ month: "long",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function formatFileSize(bytes: number) {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+}
+
+export function QuestionThread({
+ questionId,
+ questionStatus,
+ currentUserId,
+ initialMessages,
+}: QuestionThreadProps) {
+ const [messages, setMessages] = useState(initialMessages);
+ const [text, setText] = useState("");
+ const [files, setFiles] = useState([]);
+ const [uploading, setUploading] = useState(false);
+ const [sending, setSending] = useState(false);
+ const [error, setError] = useState("");
+ const fileInputRef = useRef(null);
+
+ async function handleFileSelect(e: React.ChangeEvent) {
+ 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 handleSend() {
+ if (!text.trim() && files.length === 0) return;
+ setSending(true);
+ setError("");
+
+ try {
+ const res = await fetch(`/api/questions/${questionId}/messages`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: text.trim(), files }),
+ });
+ if (!res.ok) {
+ const d = await res.json();
+ throw new Error(d.error ?? "Ошибка отправки");
+ }
+ const msg = await res.json();
+ setMessages((prev) => [...prev, msg]);
+ setText("");
+ setFiles([]);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Ошибка");
+ } finally {
+ setSending(false);
+ }
+ }
+
+ return (
+
+ {/* Messages */}
+
+ {messages.map((msg) => {
+ const isMine = msg.authorId === currentUserId;
+ const isNew = !msg.isRead && !isMine;
+ return (
+
+
+ {isMine ? "Ты" : msg.author.name} · {formatDate(msg.createdAt)}
+ {isNew && (
+ · 🔵 новое
+ )}
+
+
{msg.text}
+ {msg.files && msg.files.length > 0 && (
+
+ )}
+
+ );
+ })}
+
+
+ {/* Queued files preview */}
+ {files.length > 0 && (
+
+ {files.map((f, i) => (
+
+ 📎 {f.name}
+
+
+ ))}
+
+ )}
+
+ {/* Reply form */}
+
+
+
+ );
+}