import { useCallback, useEffect, useMemo, useState } from "react" import { Link, useLocation, useParams } from "react-router-dom" import { ArrowLeft, Plus, Edit, Trash2, X, Check, ChevronDown, ChevronUp, SlidersHorizontal, ArrowUpDown } from "lucide-react" import practiceSrc from "../../assets/Practice.svg" import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import alertSrc from "../../assets/Alert.svg" import { Badge } from "../../components/ui/badge" import { Button } from "../../components/ui/button" import { getQuestionSetById, getPracticeQuestionsByPractice, getQuestionById, deleteQuestion, updateQuestion, createQuestion, addQuestionToSet, } from "../../api/courses.api" import { Input } from "../../components/ui/input" import { Select } from "../../components/ui/select" import { Textarea } from "../../components/ui/textarea" import type { PracticeQuestion, QuestionSetQuestion, QuestionDetail } from "../../types/course.types" import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination" type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" type DifficultyLevel = "EASY" | "MEDIUM" | "HARD" type GroupByOption = "none" | "type" | "difficulty" type PointsSortOption = "asc" | "desc" interface DraftOption { text: string isCorrect: boolean } interface QuestionDraft { questionText: string questionType: QuestionType difficultyLevel: DifficultyLevel points: number options: DraftOption[] tips: string explanation: string questionVoicePrompt: string sampleAnswerVoicePrompt: string } const typeLabels: Record = { MCQ: "Multiple Choice", TRUE_FALSE: "True/False", SHORT: "Short Answer", AUDIO: "Audio", } const typeColors: Record = { MCQ: "bg-blue-100 text-blue-700", TRUE_FALSE: "bg-purple-100 text-purple-700", SHORT: "bg-green-100 text-green-700", AUDIO: "bg-brand-100 text-brand-700", } export function PracticeQuestionsPage() { const { categoryId, courseId, subModuleId, levelId, practiceId } = useParams<{ categoryId: string courseId: string subModuleId?: string levelId?: string practiceId?: string }>() const location = useLocation() const [questions, setQuestions] = useState([]) const [practiceTitle, setPracticeTitle] = useState("Practice Questions") const [practiceDescription, setPracticeDescription] = useState("") const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [showAddModal, setShowAddModal] = useState(false) const [showEditModal, setShowEditModal] = useState(false) const [questionToEdit, setQuestionToEdit] = useState(null) const [showDeleteModal, setShowDeleteModal] = useState(false) const [questionToDelete, setQuestionToDelete] = useState(null) const [deleting, setDeleting] = useState(false) const [expandedQuestionId, setExpandedQuestionId] = useState(null) const [questionDetailsById, setQuestionDetailsById] = useState>({}) const [loadingDetailIds, setLoadingDetailIds] = useState>({}) const [groupBy, setGroupBy] = useState("none") const [pointsSort, setPointsSort] = useState("desc") const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE) const [currentPage, setCurrentPage] = useState(1) const [totalQuestions, setTotalQuestions] = useState(0) const [draft, setDraft] = useState({ questionText: "", questionType: "MCQ", difficultyLevel: "EASY", points: 1, options: [ { text: "", isCorrect: true }, { text: "", isCorrect: false }, { text: "", isCorrect: false }, { text: "", isCorrect: false }, ], tips: "", explanation: "", questionVoicePrompt: "", sampleAnswerVoicePrompt: "", }) const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(null) const backLink = useMemo(() => { if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/level/") && levelId) { return "/content/human-language" } if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/") && subModuleId) { return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}` } return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}` }, [location.pathname, categoryId, courseId, subModuleId, levelId]) const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => { if (type === "TRUE_FALSE") { const normalized = sampleAnswerText?.trim().toLowerCase() const isTrue = normalized === "true" const isFalse = normalized === "false" return [ { text: "True", isCorrect: isTrue }, { text: "False", isCorrect: isFalse }, ] } return [ { text: sampleAnswerText?.trim() || "", isCorrect: !!sampleAnswerText?.trim() }, { text: "", isCorrect: false }, { text: "", isCorrect: false }, { text: "", isCorrect: false }, ] } const resetDraft = () => { setDraft({ questionText: "", questionType: "MCQ", difficultyLevel: "EASY", points: 1, options: buildDefaultOptions("MCQ"), tips: "", explanation: "", questionVoicePrompt: "", sampleAnswerVoicePrompt: "", }) } const getResolvedSampleAnswer = () => { // For SHORT questions, backend still expects sample_answer. // Use explanation when provided, otherwise send a neutral placeholder. if (draft.questionType === "SHORT") return draft.explanation.trim() || "N/A" const correctOption = draft.options.find((opt) => opt.isCorrect && opt.text.trim()) return correctOption?.text.trim() || "" } const toCreateQuestionType = (type: QuestionType) => type === "SHORT" ? "SHORT_ANSWER" : type const isDraftValid = () => { if (!draft.questionText.trim()) return false if (draft.questionType === "SHORT") return true if (draft.questionType === "MCQ") { const nonEmpty = draft.options.filter((opt) => opt.text.trim()) if (nonEmpty.length < 2) return false } return !!getResolvedSampleAnswer() } const handleDraftTypeChange = (type: QuestionType) => { setDraft((prev) => ({ ...prev, questionType: type, options: buildDefaultOptions(type), })) } const updateDraftOption = (index: number, patch: Partial) => { setDraft((prev) => ({ ...prev, options: prev.options.map((opt, idx) => (idx === index ? { ...opt, ...patch } : opt)), })) } const setDraftCorrectOption = (index: number) => { setDraft((prev) => ({ ...prev, options: prev.options.map((opt, idx) => ({ ...opt, isCorrect: idx === index })), })) } const addDraftOption = () => { setDraft((prev) => ({ ...prev, options: [...prev.options, { text: "", isCorrect: false }], })) } const removeDraftOption = (index: number) => { setDraft((prev) => { if (prev.options.length <= 2) return prev const nextOptions = prev.options.filter((_, idx) => idx !== index) const hasCorrect = nextOptions.some((opt) => opt.isCorrect) return { ...prev, options: hasCorrect ? nextOptions : nextOptions.map((opt, idx) => ({ ...opt, isCorrect: idx === 0 })), } }) } const fetchQuestions = useCallback(async (page: number = currentPage) => { if (!practiceId) return try { const safePage = page < 1 ? 1 : page const offset = (safePage - 1) * pageSize const [detailRes, questionsRes] = await Promise.all([ getQuestionSetById(Number(practiceId)), getPracticeQuestionsByPractice(Number(practiceId), { limit: pageSize, offset }), ]) const detail = detailRes.data?.data setPracticeTitle(detail?.title || "Practice Questions") setPracticeDescription(detail?.description || "") const payload = questionsRes.data?.data const mappedQuestions: PracticeQuestion[] = (payload?.questions ?? []).map( (question: QuestionSetQuestion) => ({ id: question.question_id || question.id, practice_id: question.set_id, question: question.question_text || "", points: question.points ?? 0, difficulty_level: question.difficulty_level || "", question_voice_prompt: question.voice_prompt || "", sample_answer_voice_prompt: question.sample_answer_voice_prompt || "", sample_answer: question.audio_correct_answer_text || question.explanation || "", tips: question.tips || "", type: question.question_type === "MCQ" || question.question_type === "TRUE_FALSE" || question.question_type === "SHORT" || question.question_type === "AUDIO" ? question.question_type : question.question_type === "SHORT_ANSWER" ? "SHORT" : "MCQ", }), ) setQuestions(mappedQuestions) setTotalQuestions(payload?.total_count ?? mappedQuestions.length) setCurrentPage(safePage) } catch (err) { console.error("Failed to fetch questions:", err) setError("Failed to load questions") } finally { setLoading(false) } }, [practiceId, currentPage, pageSize]) useEffect(() => { fetchQuestions() }, [fetchQuestions]) const handleAddQuestion = () => { resetDraft() setSaveError(null) setShowAddModal(true) } const handleSaveNewQuestion = async () => { if (!practiceId) return setSaving(true) setSaveError(null) try { const resolvedSampleAnswer = getResolvedSampleAnswer() const createRes = await createQuestion({ question_text: draft.questionText, question_type: toCreateQuestionType(draft.questionType), status: "PUBLISHED", difficulty_level: draft.difficultyLevel, points: draft.points, tips: draft.tips || undefined, explanation: draft.explanation || undefined, voice_prompt: draft.questionVoicePrompt || undefined, sample_answer_voice_prompt: draft.sampleAnswerVoicePrompt || undefined, options: draft.questionType === "SHORT" ? undefined : draft.options .filter((opt) => opt.text.trim()) .map((opt, idx) => ({ option_order: idx + 1, option_text: opt.text.trim(), is_correct: opt.isCorrect, })), short_answers: draft.questionType === "SHORT" ? [ { acceptable_answer: resolvedSampleAnswer, match_type: "EXACT", }, { acceptable_answer: resolvedSampleAnswer, match_type: "CASE_INSENSITIVE", }, ] : undefined, }) const createdQuestionId = createRes.data?.data?.id if (!createdQuestionId) throw new Error("Question created but no question ID returned.") await addQuestionToSet(Number(practiceId), { question_id: createdQuestionId }) setShowAddModal(false) resetDraft() await fetchQuestions() } catch (err) { console.error("Failed to create question:", err) setSaveError("Failed to create question") } finally { setSaving(false) } } const handleEditClick = async (question: PracticeQuestion) => { setQuestionToEdit(question) const fallbackDraft: QuestionDraft = { questionText: question.question, questionType: question.type, difficultyLevel: "EASY", points: 1, options: buildDefaultOptions(question.type, question.sample_answer), tips: question.tips || "", explanation: question.sample_answer || "", questionVoicePrompt: question.question_voice_prompt || "", sampleAnswerVoicePrompt: question.sample_answer_voice_prompt || "", } setDraft(fallbackDraft) setSaveError(null) try { const detailRes = await getQuestionById(question.id) const detail = detailRes.data?.data if (detail) { const normalizedType: QuestionType = detail.question_type === "MCQ" || detail.question_type === "TRUE_FALSE" || detail.question_type === "SHORT" ? detail.question_type : detail.question_type === "SHORT_ANSWER" ? "SHORT" : "MCQ" const detailOptions = (detail.options ?? []) .slice() .sort((a, b) => (a.option_order ?? 0) - (b.option_order ?? 0)) .map((opt) => ({ text: opt.option_text || "", isCorrect: !!opt.is_correct, })) const shortAnswerFromDetail = Array.isArray(detail.short_answers) ? typeof detail.short_answers[0] === "string" ? String(detail.short_answers[0] || "") : String((detail.short_answers[0] as { acceptable_answer?: string })?.acceptable_answer || "") : "" setDraft({ questionText: detail.question_text || fallbackDraft.questionText, questionType: normalizedType, difficultyLevel: detail.difficulty_level === "EASY" || detail.difficulty_level === "MEDIUM" || detail.difficulty_level === "HARD" ? detail.difficulty_level : "EASY", points: detail.points && detail.points > 0 ? detail.points : 1, options: normalizedType === "SHORT" ? buildDefaultOptions("SHORT") : detailOptions.length > 0 ? detailOptions : buildDefaultOptions(normalizedType, question.sample_answer), tips: detail.tips || "", explanation: detail.explanation || shortAnswerFromDetail || fallbackDraft.explanation, questionVoicePrompt: detail.voice_prompt || "", sampleAnswerVoicePrompt: detail.sample_answer_voice_prompt || "", }) } } catch (err) { console.error("Failed to fetch question details:", err) } finally { setShowEditModal(true) } } const handleSaveEditQuestion = async () => { if (!questionToEdit) return setSaving(true) setSaveError(null) try { const resolvedSampleAnswer = getResolvedSampleAnswer() await updateQuestion(questionToEdit.id, { question_text: draft.questionText, question_type: toCreateQuestionType(draft.questionType), status: "PUBLISHED", difficulty_level: draft.difficultyLevel, points: draft.points, tips: draft.tips || undefined, explanation: draft.explanation || undefined, voice_prompt: draft.questionVoicePrompt || undefined, sample_answer_voice_prompt: draft.sampleAnswerVoicePrompt || undefined, options: draft.questionType === "SHORT" ? undefined : draft.options .filter((opt) => opt.text.trim()) .map((opt, idx) => ({ option_order: idx + 1, option_text: opt.text.trim(), is_correct: opt.isCorrect, })), short_answers: draft.questionType === "SHORT" ? [ { acceptable_answer: resolvedSampleAnswer, match_type: "EXACT", }, { acceptable_answer: resolvedSampleAnswer, match_type: "CASE_INSENSITIVE", }, ] : undefined, }) setShowEditModal(false) setQuestionToEdit(null) resetDraft() await fetchQuestions() } catch (err) { console.error("Failed to update question:", err) setSaveError("Failed to update question") } finally { setSaving(false) } } const handleDeleteClick = (question: PracticeQuestion) => { setQuestionToDelete(question) setShowDeleteModal(true) } const loadQuestionDetails = async (questionId: number) => { if (questionDetailsById[questionId] || loadingDetailIds[questionId]) return setLoadingDetailIds((prev) => ({ ...prev, [questionId]: true })) try { const detailRes = await getQuestionById(questionId) const detail = detailRes.data?.data if (detail) { setQuestionDetailsById((prev) => ({ ...prev, [questionId]: detail })) } } catch (err) { console.error("Failed to fetch question details:", err) } finally { setLoadingDetailIds((prev) => ({ ...prev, [questionId]: false })) } } const handleToggleDetails = (questionId: number) => { setExpandedQuestionId((prev) => { const next = prev === questionId ? null : questionId if (next === questionId) { void loadQuestionDetails(questionId) } return next }) } const groupedQuestions = useMemo(() => { const sorted = [...questions].sort((a, b) => { const aPoints = a.points ?? 0 const bPoints = b.points ?? 0 return pointsSort === "asc" ? aPoints - bPoints : bPoints - aPoints }) if (groupBy === "none") { return [{ key: "all", label: "All Questions", items: sorted }] } const groups = new Map() sorted.forEach((question) => { const label = groupBy === "type" ? typeLabels[question.type] : question.difficulty_level || "Unspecified" if (!groups.has(label)) groups.set(label, []) groups.get(label)?.push(question) }) return Array.from(groups.entries()).map(([label, items], index) => ({ key: `${groupBy}-${label}-${index}`, label, items, })) }, [questions, groupBy, pointsSort]) const handleConfirmDelete = async () => { if (!questionToDelete) return setDeleting(true) try { await deleteQuestion(questionToDelete.id) setShowDeleteModal(false) setQuestionToDelete(null) await fetchQuestions() } catch (err) { console.error("Failed to delete question:", err) } finally { setDeleting(false) } } if (loading) { return (

Loading questions...

) } if (error) { return (

{error}

) } return (

{practiceTitle}

{practiceDescription && (

{practiceDescription}

)}

{questions.length} questions on this page

Total: {totalQuestions} question{totalQuestions === 1 ? "" : "s"}

{questions.length === 0 ? (

No questions found for this practice

Get started by adding your first question

) : (

Question Controls

Group and sort the list quickly

Group: {groupBy === "none" ? "None" : groupBy === "type" ? "Type" : "Difficulty"} Points: {pointsSort === "desc" ? "High to Low" : "Low to High"}
{groupedQuestions.map((group) => (
{groupBy !== "none" && (

{group.label}

{group.items.length} question(s)

)} {group.items.map((question) => (
{question.question}
Type: {typeLabels[question.type]} Points: {question.points ?? 0} Difficulty: {question.difficulty_level || "—"}
{expandedQuestionId === question.id && (
{typeLabels[question.type]}

Sample Answer

{question.sample_answer}

{question.type === "MCQ" && (

Options

{loadingDetailIds[question.id] ? (

Loading options...

) : (questionDetailsById[question.id]?.options ?? []).length > 0 ? (
{(questionDetailsById[question.id]?.options ?? []) .slice() .sort((a, b) => a.option_order - b.option_order) .map((option) => (
{option.option_order}.{" "} {option.option_text}
))}
) : (

No options available.

)}
)} {question.tips && (

💡 Tips

{question.tips}

)}
)}
))}
))} {totalQuestions > 0 && (
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))} ({totalQuestions}{" "} total) Rows per page
{totalQuestions > pageSize ? (
) : null}
)}
)} {/* Delete Modal */} {showDeleteModal && questionToDelete && (

Delete Question

Are you sure you want to delete this question? This action cannot be undone.

)} {/* Add Modal */} {showAddModal && (

Add New Question