import { useState, useEffect, useRef, useCallback, memo } from "react"; import { useQuery } from "@tanstack/react-query"; import { Button } from "@pram/ui/components/button"; import { MaterialIcon } from "@/components/ui/MaterialIcon"; import { trpc } from "@/utils/trpc"; import { QuestionInput } from "./QuestionInput"; import { AccentKeyboard } from "./AccentKeyboard"; import { parseFurigana } from "@/lib/furigana"; import type { Question, Package, PackageSection } from "@/lib/types"; import { AttemptHeader } from "./attempt/AttemptHeader"; import { FloatingNav } from "./attempt/FloatingNav"; import { FinishDialog } from "./attempt/FinishDialog"; import { AbandonDialog } from "./attempt/AbandonDialog"; interface AttemptTestViewProps { attemptId: string; pkg: Package & { sections: PackageSection[] }; 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: Question & { _examType?: string; _isRtl?: boolean; _useFurigana?: boolean }; 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, }: 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); 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.examTypeName ?? ""; 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 ?? []) { const v = answers[q.id]; if (v) void onAnswerChange(q.id, sectionResultId, v); } /* 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: PackageSection, sIdx: number) => { sec.questions?.forEach((q: Question, qIdx: number) => { allQuestions.push({ id: q.id, sectionIdx: sIdx, localIdx: qIdx, passageText: q.passageText ?? undefined }); }); }); // Initialize active question once on mount useEffect(() => { if (!hasInitRef.current && allQuestions.length > 0) { hasInitRef.current = true; setActiveQuestionId(allQuestions[0].id); } }, [allQuestions]); const addQuestionMeta = (q: Question) => ({ ...q, _examType: examType, _isRtl: isRtl, _useFurigana: useFurigana, }); const activeQuestion = currentSection?.questions?.find( (q: Question) => 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 ( <>
setShowAbandonDialog(true)} onFinish={() => setShowFinishDialog(true)} /> {/* 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} {markedQuestions.has(activeQuestionId ?? "") && ( Ditandai )}
{/* Active question only */} {activeQuestionWithMeta ? ( ) : (

Tidak ada soal aktif.

)} {/* Prev / Next Navigation */}
questionPanelRef.current?.scrollTo({ top: 0, behavior: "smooth" })} navSliderRef={navSliderRef} />
setShowFinishDialog(false)} onConfirm={() => { setShowFinishDialog(false); onFinish(); }} answeredCount={answeredCount} totalQuestions={totalQuestions} /> setShowAbandonDialog(false)} onConfirm={() => { setShowAbandonDialog(false); onAbandon(); }} /> ); }