Add homework review workflow: statuses, audio, file attachments, tabs
- HomeworkSubmission: add status (PENDING/REVIEWING/APPROVED/REJECTED) + statusAt - HomeworkFeedback: add files (Json) + audioUrl fields - Curator detail page: meta table, content tabs, feedback history with audio/files - FeedbackForm: file upload, audio recorder (Web Audio API + S3), action buttons - AudioRecorder component: record → preview → upload to S3 - ContentTabs: toggle between homework description and lesson content (TipTap read-only) - Homework list: 4-color status badges with proper filtering - API routes: /api/curator/upload and /api/curator/audio-upload Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,33 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useState, useTransition, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { submitFeedback } from "./actions";
|
||||
import Link from "next/link";
|
||||
import { submitFeedback, setReviewing } from "./actions";
|
||||
import { AudioRecorder } from "@/components/curator/audio-recorder";
|
||||
|
||||
export function FeedbackForm({ submissionId }: { submissionId: string }) {
|
||||
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();
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
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.trim());
|
||||
await submitFeedback(submissionId, {
|
||||
text: text.trim(),
|
||||
files,
|
||||
audioUrl,
|
||||
action,
|
||||
});
|
||||
router.push("/curator/homework");
|
||||
});
|
||||
}
|
||||
|
||||
function handleReviewing() {
|
||||
startTransition(async () => {
|
||||
await setReviewing(submissionId);
|
||||
});
|
||||
}
|
||||
|
||||
const isWorking = pending || uploading;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||
Написать фидбек
|
||||
<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)}
|
||||
required
|
||||
placeholder="Напишите обратную связь студенту..."
|
||||
disabled={isWorking}
|
||||
style={{
|
||||
border: "2px solid var(--border)",
|
||||
background: "var(--background)",
|
||||
@@ -42,14 +100,110 @@ export function FeedbackForm({ submissionId }: { submissionId: string }) {
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user