import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { apiGet, apiPost } from "../../lib/api"; export interface QuestionMeta { number: number; text: string; options: string[]; } export interface StudentRow { name: string; answers: (string | null)[]; } export interface AnswerGrid { total_questions: number; official_answers: (string | null)[]; students: StudentRow[]; questions: QuestionMeta[]; } interface AnswerGridResponse { grid: AnswerGrid; is_confirmed: boolean; } interface AnswerGridEditorProps { sessionId: number; onConfirmedChange: (grid: AnswerGrid | null) => void; } const CORRECT_MARKER = "="; const DASH_MARKERS = new Set(["-", "–", "—"]); const AUTO_SAVE_DELAY_MS = 900; function normalizeCell(raw: string): string | null { const trimmed = raw.trim(); if (!trimmed) return null; if (trimmed === CORRECT_MARKER || DASH_MARKERS.has(trimmed)) { return CORRECT_MARKER; } const first = trimmed.charAt(0).toUpperCase(); return /^[A-Z]$/.test(first) ? first : null; } function resizeRow(row: (string | null)[], n: number): (string | null)[] { if (row.length === n) return row; if (row.length > n) return row.slice(0, n); return [...row, ...Array(n - row.length).fill(null)]; } function resizeQuestions(qs: QuestionMeta[], n: number): QuestionMeta[] { if (qs.length === n) return qs; if (qs.length > n) return qs.slice(0, n); const pad: QuestionMeta[] = []; for (let i = qs.length; i < n; i++) { pad.push({ number: i + 1, text: "", options: [] }); } return [...qs, ...pad]; } export function AnswerGridEditor({ sessionId, onConfirmedChange, }: AnswerGridEditorProps) { const [grid, setGrid] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState(""); useEffect(() => { setLoading(true); apiGet(`/api/sessions/${sessionId}/answer-grid`) .then((res) => { setGrid(res.grid); setSaved(res.is_confirmed); if (res.is_confirmed) { onConfirmedChange(res.grid); } }) .catch((e) => setError(e instanceof Error ? e.message : "Failed to load grid")) .finally(() => setLoading(false)); }, [sessionId]); const qNums = useMemo(() => { if (!grid) return []; return Array.from({ length: grid.total_questions }, (_, i) => i + 1); }, [grid]); const saveTimeoutRef = useRef | null>(null); const handleSaveRef = useRef<() => void>(() => {}); const markDirty = useCallback(() => { setSaved(false); onConfirmedChange(null); // Debounce so rapid edits (typing across cells) collapse into one save // instead of firing a request per keystroke/blur. if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = setTimeout(() => { saveTimeoutRef.current = null; handleSaveRef.current(); }, AUTO_SAVE_DELAY_MS); }, [onConfirmedChange]); // Flush any pending auto-save immediately if the component unmounts // (e.g. teacher navigates away right after an edit) so it isn't lost. useEffect(() => { return () => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); handleSaveRef.current(); } }; }, []); const updateN = useCallback( (n: number) => { if (!grid || n < 1) return; setGrid({ ...grid, total_questions: n, official_answers: resizeRow(grid.official_answers, n), students: grid.students.map((s) => ({ ...s, answers: resizeRow(s.answers, n), })), questions: resizeQuestions(grid.questions, n), }); markDirty(); }, [grid, markDirty] ); const updateStudentCell = useCallback( (studentIdx: number, qIdx: number, raw: string) => { if (!grid) return; const value = normalizeCell(raw); if (value === (grid.students[studentIdx]?.answers[qIdx] ?? null)) return; const nextStudents = grid.students.map((s, i) => { if (i !== studentIdx) return s; const nextAnswers = [...s.answers]; nextAnswers[qIdx] = value; return { ...s, answers: nextAnswers }; }); setGrid({ ...grid, students: nextStudents }); markDirty(); }, [grid, markDirty] ); const updateOfficialCell = useCallback( (qIdx: number, raw: string) => { if (!grid) return; const value = normalizeCell(raw); if (value === (grid.official_answers[qIdx] ?? null)) return; const next = [...grid.official_answers]; next[qIdx] = value; setGrid({ ...grid, official_answers: next }); markDirty(); }, [grid, markDirty] ); const handleSave = useCallback(async () => { if (!grid) return; if (saving) { // A save is already in flight — reschedule instead of dropping this // edit or firing an overlapping request. saveTimeoutRef.current = setTimeout(() => handleSaveRef.current(), AUTO_SAVE_DELAY_MS); return; } setSaving(true); setError(""); try { const res = await apiPost( `/api/sessions/${sessionId}/answer-grid`, grid ); setGrid(res.grid); setSaved(true); onConfirmedChange(res.grid); } catch (e) { setError(e instanceof Error ? e.message : "Failed to save grid"); } finally { setSaving(false); } }, [grid, saving, sessionId, onConfirmedChange]); useEffect(() => { handleSaveRef.current = handleSave; }, [handleSave]); if (loading) { return (
); } if (!grid) { return (

{error || "尚未上傳任何資料,請回到上一步先解析檔案"}

); } return (

確認答案表 (Answer Grid)

請核對學生作答與標準答案,確認後才能生成報告。支援單選題(A-Z)。 學生欄輸入 = 代表該題答對。

updateN(Number(e.target.value))} className="input !w-20 !py-1 text-sm" />
{qNums.map((n) => ( ))} {grid.students.map((s, si) => ( {qNums.map((n, qi) => { const cell = s.answers[qi]; const isCorrect = cell === CORRECT_MARKER; return ( ); })} ))} {/* Answer key row */} {qNums.map((n, qi) => ( ))}
學生 Q{n}
{s.name} updateStudentCell(si, qi, e.target.value)} className={`w-full text-center py-1 bg-transparent border border-transparent hover:border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none rounded ${ isCorrect ? "text-[var(--color-success)] font-semibold" : "text-[var(--color-text)]" }`} />
標準答案 updateOfficialCell(qi, e.target.value)} className="w-full text-center py-1 bg-transparent text-[var(--color-success)] font-semibold border border-transparent hover:border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none rounded" />
{error && (

{error}

)}
{saving ? ( 儲存中... ) : saved ? ( 已自動儲存 — 可生成報告 ) : ( ⚠ 尚未儲存 — 編輯後將自動儲存 )}
); }