import { useState, useEffect, useRef, useCallback, memo } from "react"; import { Link } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { Button } from "@labas/ui/components/button"; import { MaterialIcon } from "@/components/ui/MaterialIcon"; import { formatTime } from "@/lib/time"; import { trpc } from "@/utils/trpc"; import { QuestionInput } from "./QuestionInput"; import { AccentKeyboard } from "./AccentKeyboard"; import { parseFurigana } from "@/lib/furigana"; interface AttemptTestViewProps { attemptId: string; pkg: any; currentSectionIdx: number; setCurrentSectionIdx: (idx: number) => void; answers: Record; onAnswerChange: (questionId: string, sectionResultId: string | undefined, value: string) => void; timeElapsed: number; answeredCount: number; totalQuestions: number; onFinish: () => void; onAbandon: () => void; isFinished: boolean; submittingQId: string | null; markedQuestions: Set; toggleMarkQuestion: (questionId: string) => void; startQuestionTimer: (questionId: string) => void; } interface QuestionCardProps { q: any; globalIdx: number; answerValue: string; sectionResultId: string | undefined; isMarked: boolean; isFinished: boolean; isSubmitting: boolean; onAnswerChange: (questionId: string, sectionResultId: string | undefined, value: string) => void; toggleMarkQuestion: (questionId: string) => void; } const QuestionCard = memo(function QuestionCard({ q, globalIdx, answerValue, sectionResultId, isMarked, isFinished, isSubmitting, onAnswerChange, toggleMarkQuestion, }: QuestionCardProps) { const handleChange = useCallback( (val: string) => { onAnswerChange(q.id, sectionResultId, val); }, [q.id, sectionResultId, onAnswerChange], ); const handleAccentInsert = useCallback( (char: string) => { onAnswerChange(q.id, sectionResultId, answerValue + char); }, [q.id, sectionResultId, answerValue, onAnswerChange], ); const showAccentKeyboard = q.format === "fill_blank" || q.format === "sentence_completion"; return (
{globalIdx}

{q.format.replace(/_/g, " ")}

{q._useFurigana ? parseFurigana(q.questionText) : q.questionText}

{isSubmitting && (
Menyimpan...
)} {showAccentKeyboard && (
)}
); }); export function AttemptTestView({ attemptId, pkg, currentSectionIdx, setCurrentSectionIdx, answers, onAnswerChange, timeElapsed, answeredCount, totalQuestions, onFinish, onAbandon, isFinished, submittingQId, markedQuestions, toggleMarkQuestion, startQuestionTimer, }: AttemptTestViewProps) { const [showFinishDialog, setShowFinishDialog] = useState(false); const [showAbandonDialog, setShowAbandonDialog] = useState(false); const [activeQuestionId, setActiveQuestionId] = useState(null); const questionPanelRef = useRef(null); const navSliderRef = useRef(null); const hasInitRef = useRef(false); console.log("[AttemptTestView] render, attemptId:", attemptId, "sections:", pkg.sections?.length); const attemptQuery = useQuery( trpc.attempt.getById.queryOptions( { id: attemptId }, { enabled: !!attemptId }, ), ); const attempt = attemptQuery.data; const currentSection = pkg.sections[currentSectionIdx]; const sectionData = attempt?.sections?.[currentSectionIdx]; const sectionResultId = sectionData?.sectionResultId; const examType: string = pkg.examType ?? ""; const isRtl = examType === "TOAFL"; const useFurigana = examType === "JLPT" || examType === "TOPIK"; // If user picks an answer before attempt.getById finishes, persist once sectionResultId exists const flushKeyRef = useRef(null); useEffect(() => { if (!sectionResultId || isFinished || !currentSection?.questions?.length) return; const key = `${currentSectionIdx}:${sectionResultId}`; if (flushKeyRef.current === key) return; flushKeyRef.current = key; for (const q of currentSection.questions as any[]) { const v = answers[q.id]; if (v) void onAnswerChange(q.id, sectionResultId, v); } // eslint-disable-next-line react-hooks/exhaustive-deps -- flush once per section when ids align; `answers` read from committing render }, [sectionResultId, currentSectionIdx, isFinished, currentSection?.questions?.length, onAnswerChange]); if (!currentSection) { return (

Section tidak ditemukan.

); } // Build global question index across all sections const allQuestions: Array<{ id: string; sectionIdx: number; localIdx: number; passageText?: string }> = []; pkg.sections.forEach((sec: any, sIdx: number) => { sec.questions.forEach((q: any, qIdx: number) => { allQuestions.push({ id: q.id, sectionIdx: sIdx, localIdx: qIdx, passageText: q.passageText }); }); }); const isAnswered = (qId: string) => !!answers[qId]; const isMarked = (qId: string) => markedQuestions.has(qId); // Initialize active question once on mount useEffect(() => { if (!hasInitRef.current && allQuestions.length > 0) { hasInitRef.current = true; setActiveQuestionId(allQuestions[0].id); } }, [allQuestions]); const addQuestionMeta = (q: any) => ({ ...q, _examType: examType, _isRtl: isRtl, _useFurigana: useFurigana, }); const activeQuestion = currentSection?.questions?.find( (q: any) => q.id === activeQuestionId, ); const activeQuestionWithMeta = activeQuestion ? addQuestionMeta(activeQuestion) : null; const passageToShow = activeQuestion?.passageText ?? currentSection?.questions?.[0]?.passageText ?? "Tidak ada bacaan tambahan untuk section ini."; const activeGlobalIdx = activeQuestionId ? allQuestions.findIndex((q) => q.id === activeQuestionId) + 1 : 0; const isFirstQuestion = activeGlobalIdx === 1; const isLastQuestion = activeGlobalIdx === allQuestions.length; // Navigation helpers const goToQuestion = useCallback( (qId: string | null) => { if (!qId) return; const target = allQuestions.find((q) => q.id === qId); if (!target) return; setCurrentSectionIdx(target.sectionIdx); setActiveQuestionId(qId); // Scroll question panel to top when changing question setTimeout(() => { questionPanelRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }, 50); // Scroll nav slider to keep active button visible setTimeout(() => { const btn = navSliderRef.current?.querySelector(`[data-qid="${qId}"]`) as HTMLElement | null; btn?.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" }); }, 100); }, [allQuestions, setCurrentSectionIdx], ); const goToPrevQuestion = useCallback(() => { if (!activeQuestionId) return; const currentIdx = allQuestions.findIndex((q) => q.id === activeQuestionId); if (currentIdx > 0) { goToQuestion(allQuestions[currentIdx - 1].id); } }, [activeQuestionId, allQuestions, goToQuestion]); const goToNextQuestion = useCallback(() => { if (!activeQuestionId) return; const currentIdx = allQuestions.findIndex((q) => q.id === activeQuestionId); if (currentIdx >= 0 && currentIdx < allQuestions.length - 1) { goToQuestion(allQuestions[currentIdx + 1].id); } }, [activeQuestionId, allQuestions, goToQuestion]); return ( <>
{/* TopAppBar Shell */}
{pkg.title} {currentSection.title}
{/* Timer Box */}
Waktu Berlalu {formatTime(timeElapsed)}
0 ? (answeredCount / totalQuestions) * 100 : 0}%` }} />
{answeredCount}/{totalQuestions} Dijawab
{/* Main Exam Workspace */}
{/* Left Column: Reading Passage */}

{currentSection.title}

Section {currentSectionIdx + 1} dari {pkg.sections.length}
{useFurigana ? parseFurigana(passageToShow) : passageToShow}
{/* Right Column: Question Panel */}
{/* Top bar: question counter + Selesai button */}
Soal {activeGlobalIdx} / {totalQuestions} {isMarked(activeQuestionId ?? "") && ( Ditandai )}
{/* Active question only */} {activeQuestionWithMeta ? ( ) : (

Tidak ada soal aktif.

)} {/* Prev / Next Navigation */}
{/* Floating Question Navigation Slider */}
{/* Scroll Left */} {/* Scrollable Strip */}
{allQuestions.map((q, gIdx) => { const answered = isAnswered(q.id); const marked = isMarked(q.id); const isActive = q.id === activeQuestionId; return ( ); })}
{/* Scroll Right */}
{/* Top Button */}
{/* Finish Confirmation Dialog */} {showFinishDialog && (
{ if (e.target === e.currentTarget) setShowFinishDialog(false); }} >

Selesaikan Latihan?

Kamu sudah menjawab {answeredCount} dari {totalQuestions} soal.

{answeredCount < totalQuestions && (
Masih ada {totalQuestions - answeredCount} soal yang belum dijawab.
)}

Setelah selesai, jawaban tidak bisa diubah dan hasil akan langsung terlihat.

)} {/* Abandon Confirmation Dialog */} {showAbandonDialog && (
{ if (e.target === e.currentTarget) setShowAbandonDialog(false); }} >

Keluar dari Latihan?

Apakah Anda yakin ingin meninggalkan sesi latihan ini? Progress pengerjaan Anda mungkin tidak tersimpan dan akan ditandai sebagai gagal atau dibatalkan.

)} ); }