Files
lms-sb/src/app/curator/homework/[submissionId]/feedback-form.tsx
T
admins 2436f77de5 Add 'approve without feedback' button for homework review
Curators/admins can mark a submission APPROVED (and complete the lesson)
without writing feedback. The student sees 'ДЗ принято ✓' and can no
longer resubmit. No email is sent on silent approval.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:10:29 +05:00

228 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useTransition, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { submitFeedback, setReviewing, approveWithoutFeedback } from "./actions";
import { AudioRecorder } from "@/components/curator/audio-recorder";
interface FileItem {
name: string;
url: string;
size: number;
}
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 FeedbackForm({
submissionId,
currentStatus,
}: {
submissionId: string;
currentStatus: string;
}) {
const [text, setText] = useState("");
const [files, setFiles] = useState<FileItem[]>([]);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [pending, startTransition] = useTransition();
const fileInputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const picked = Array.from(e.target.files ?? []);
if (!picked.length) return;
setUploading(true);
const uploaded: FileItem[] = [];
for (const f of picked) {
const form = new FormData();
form.append("file", f);
const res = await fetch("/api/curator/upload", { method: "POST", body: form });
const data = await res.json();
if (data.url) uploaded.push({ name: data.name, url: data.url, size: data.size });
}
setFiles((prev) => [...prev, ...uploaded]);
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
function handleAction(action: "approve" | "reject") {
if (!text.trim()) return;
startTransition(async () => {
await submitFeedback(submissionId, {
text: text.trim(),
files,
audioUrl,
action,
});
router.push("/curator/homework");
});
}
function handleReviewing() {
startTransition(async () => {
await setReviewing(submissionId);
});
}
function handleApproveNoFeedback() {
startTransition(async () => {
await approveWithoutFeedback(submissionId);
router.push("/curator/homework");
});
}
const isWorking = pending || uploading;
return (
<div className="space-y-4">
<p
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Ваш ответ
</p>
{/* Text */}
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Напишите обратную связь студенту..."
disabled={isWorking}
style={{
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "16px",
fontFamily: "inherit",
resize: "vertical",
minHeight: "120px",
}}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
{/* File upload */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<label
className="btn-aubade text-xs px-3 py-1.5 cursor-pointer"
style={{ opacity: isWorking ? 0.5 : 1 }}
>
📎 Прикрепить файл
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
disabled={isWorking}
/>
</label>
{uploading && (
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Загрузка...
</span>
)}
</div>
{files.length > 0 && (
<div className="space-y-1">
{files.map((f, i) => (
<div
key={f.url}
className="flex items-center gap-2 px-3 py-1.5 text-xs"
style={{ border: "1px solid var(--border)" }}
>
<span>📎</span>
<span className="flex-1 truncate">{f.name}</span>
<span style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
<button
type="button"
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
style={{ color: "oklch(0.577 0.245 27.325)" }}
>
×
</button>
</div>
))}
</div>
)}
</div>
{/* Audio recorder */}
<AudioRecorder value={audioUrl} onChange={setAudioUrl} />
{/* Action buttons */}
<div className="flex items-center gap-2 flex-wrap pt-1">
<button
type="button"
onClick={() => handleAction("approve")}
disabled={isWorking || !text.trim()}
className="btn-aubade-accent px-4 py-2 text-sm"
style={{ opacity: isWorking || !text.trim() ? 0.5 : 1 }}
>
{pending ? "Отправка..." : "Отправить ответ"}
</button>
<button
type="button"
onClick={handleApproveNoFeedback}
disabled={isWorking}
className="btn-aubade px-4 py-2 text-sm"
style={{ opacity: isWorking ? 0.5 : 1 }}
title="Засчитать ДЗ без письменного ответа"
>
Принять и отметить выполненным
</button>
{currentStatus !== "REVIEWING" && (
<button
type="button"
onClick={handleReviewing}
disabled={isWorking}
className="px-4 py-2 text-sm"
style={{
border: "2px solid var(--border)",
background: "oklch(0.9 0.08 80)",
color: "oklch(0.4 0.1 80)",
opacity: isWorking ? 0.5 : 1,
}}
>
На рассмотрение
</button>
)}
<button
type="button"
onClick={() => handleAction("reject")}
disabled={isWorking || !text.trim()}
className="px-4 py-2 text-sm"
style={{
border: "2px solid var(--border)",
background: "oklch(0.9 0.06 27)",
color: "oklch(0.45 0.2 27)",
opacity: isWorking || !text.trim() ? 0.5 : 1,
}}
>
Отклонить и отправить
</button>
<Link
href="/curator/homework"
className="px-4 py-2 text-sm"
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
К списку ДЗ
</Link>
</div>
</div>
);
}