Yimaru-Admin/src/pages/content-management/PracticeQuestionsPage.tsx
Yared Yemane 92a2fab833 feat(admin): dynamic content flows, cleaner UI copy, and table pagination
Add Learn English practice and question-type builder integrations with dynamic schema slots and HTML table editor. Remove API path labels from admin pages and standardize table page-size options to 5, 10, 30, 50, and 100.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 12:34:39 -07:00

1259 lines
56 KiB
TypeScript

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<QuestionType, string> = {
MCQ: "Multiple Choice",
TRUE_FALSE: "True/False",
SHORT: "Short Answer",
AUDIO: "Audio",
}
const typeColors: Record<QuestionType, string> = {
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<PracticeQuestion[]>([])
const [practiceTitle, setPracticeTitle] = useState("Practice Questions")
const [practiceDescription, setPracticeDescription] = useState("")
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [questionToEdit, setQuestionToEdit] = useState<PracticeQuestion | null>(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [questionToDelete, setQuestionToDelete] = useState<PracticeQuestion | null>(null)
const [deleting, setDeleting] = useState(false)
const [expandedQuestionId, setExpandedQuestionId] = useState<number | null>(null)
const [questionDetailsById, setQuestionDetailsById] = useState<Record<number, QuestionDetail>>({})
const [loadingDetailIds, setLoadingDetailIds] = useState<Record<number, boolean>>({})
const [groupBy, setGroupBy] = useState<GroupByOption>("none")
const [pointsSort, setPointsSort] = useState<PointsSortOption>("desc")
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [currentPage, setCurrentPage] = useState(1)
const [totalQuestions, setTotalQuestions] = useState(0)
const [draft, setDraft] = useState<QuestionDraft>({
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<string | null>(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<DraftOption>) => {
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<string, PracticeQuestion[]>()
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 (
<div className="flex flex-col items-center justify-center py-20">
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading questions...</p>
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-20">
<img src={alertSrc} alt="" className="h-12 w-12" />
<p className="mt-4 text-sm font-medium text-red-600">{error}</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link
to={backLink}
className="group grid h-9 w-9 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition-all hover:bg-brand-100 hover:text-brand-600 hover:shadow-sm"
>
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
</Link>
<div>
<h1 className="text-xl font-bold tracking-tight text-grayScale-900">{practiceTitle}</h1>
{practiceDescription && (
<p className="mt-0.5 text-sm text-grayScale-500">{practiceDescription}</p>
)}
<p className="mt-0.5 text-sm text-grayScale-500">{questions.length} questions on this page</p>
<p className="mt-0.5 text-xs text-grayScale-400">
Total: {totalQuestions} question{totalQuestions === 1 ? "" : "s"}
</p>
</div>
</div>
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleAddQuestion}>
<Plus className="mr-2 h-4 w-4" />
Add New Question
</Button>
</div>
{questions.length === 0 ? (
<Card className="border-2 border-dashed border-grayScale-200 shadow-none">
<CardContent className="flex flex-col items-center justify-center py-16">
<img src={practiceSrc} alt="" className="h-20 w-20" />
<p className="mt-4 text-sm font-medium text-grayScale-600">No questions found for this practice</p>
<p className="mt-1 text-xs text-grayScale-400">Get started by adding your first question</p>
<Button variant="outline" className="mt-6 border-brand-200 text-brand-600 hover:bg-brand-50" onClick={handleAddQuestion}>
<Plus className="mr-2 h-4 w-4" />
Add your first question
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4">
<div className="rounded-2xl border border-grayScale-200 bg-gradient-to-br from-white to-grayScale-50 p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="grid h-8 w-8 place-items-center rounded-lg bg-brand-100 text-brand-600">
<SlidersHorizontal className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-semibold text-grayScale-700">Question Controls</p>
<p className="text-xs text-grayScale-500">Group and sort the list quickly</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-full border border-grayScale-200 bg-white px-2.5 py-1 font-medium text-grayScale-600">
Group: {groupBy === "none" ? "None" : groupBy === "type" ? "Type" : "Difficulty"}
</span>
<span className="rounded-full border border-grayScale-200 bg-white px-2.5 py-1 font-medium text-grayScale-600">
Points: {pointsSort === "desc" ? "High to Low" : "Low to High"}
</span>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5 rounded-xl border border-grayScale-200 bg-white p-3">
<label className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
<SlidersHorizontal className="h-3.5 w-3.5" />
Group Questions By
</label>
<Select value={groupBy} onChange={(e) => setGroupBy(e.target.value as GroupByOption)}>
<option value="none">No Grouping</option>
<option value="type">Type</option>
<option value="difficulty">Difficulty</option>
</Select>
</div>
<div className="space-y-1.5 rounded-xl border border-grayScale-200 bg-white p-3">
<label className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
<ArrowUpDown className="h-3.5 w-3.5" />
Sort By Points
</label>
<Select value={pointsSort} onChange={(e) => setPointsSort(e.target.value as PointsSortOption)}>
<option value="desc">High to Low</option>
<option value="asc">Low to High</option>
</Select>
</div>
</div>
</div>
{groupedQuestions.map((group) => (
<div key={group.key} className="space-y-3">
{groupBy !== "none" && (
<div className="flex items-center justify-between rounded-md bg-grayScale-100 px-3 py-2">
<p className="text-sm font-semibold text-grayScale-700">{group.label}</p>
<p className="text-xs text-grayScale-500">{group.items.length} question(s)</p>
</div>
)}
{group.items.map((question) => (
<Card key={question.id} className="shadow-sm transition-shadow hover:shadow-md">
<CardHeader className="pb-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<CardTitle className="line-clamp-2 text-base font-medium leading-relaxed text-grayScale-900">
{question.question}
</CardTitle>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-grayScale-500">
<span className="rounded-md bg-grayScale-100 px-2 py-1">
Type: {typeLabels[question.type]}
</span>
<span className="rounded-md bg-grayScale-100 px-2 py-1">
Points: {question.points ?? 0}
</span>
<span className="rounded-md bg-grayScale-100 px-2 py-1">
Difficulty: {question.difficulty_level || "—"}
</span>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
onClick={() => handleToggleDetails(question.id)}
>
{expandedQuestionId === question.id ? "Hide Details" : "Details"}
{expandedQuestionId === question.id ? (
<ChevronUp className="ml-1 h-4 w-4" />
) : (
<ChevronDown className="ml-1 h-4 w-4" />
)}
</Button>
</div>
</CardHeader>
{expandedQuestionId === question.id && (
<CardContent className="space-y-3 border-t border-grayScale-100 pt-4">
<div className="flex items-center justify-between">
<Badge className={`${typeColors[question.type]} font-medium`}>
{typeLabels[question.type]}
</Badge>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-lg text-grayScale-400 hover:text-brand-600"
onClick={() => handleEditClick(question)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-lg text-grayScale-400 hover:bg-red-50 hover:text-red-500"
onClick={() => handleDeleteClick(question)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="rounded-lg border border-grayScale-100 bg-grayScale-50 p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-400">Sample Answer</p>
<p className="mt-2 text-sm leading-relaxed text-grayScale-700">{question.sample_answer}</p>
</div>
{question.type === "MCQ" && (
<div className="rounded-lg border border-grayScale-100 bg-white p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-400">Options</p>
{loadingDetailIds[question.id] ? (
<p className="mt-2 text-sm text-grayScale-500">Loading options...</p>
) : (questionDetailsById[question.id]?.options ?? []).length > 0 ? (
<div className="mt-3 space-y-2">
{(questionDetailsById[question.id]?.options ?? [])
.slice()
.sort((a, b) => a.option_order - b.option_order)
.map((option) => (
<div
key={`${question.id}-${option.option_order}-${option.option_text}`}
className={`rounded-md border px-3 py-2 text-sm ${
option.is_correct
? "border-green-200 bg-green-50 text-green-700"
: "border-grayScale-200 bg-grayScale-50 text-grayScale-600"
}`}
>
<span className="font-medium">{option.option_order}.</span>{" "}
{option.option_text}
</div>
))}
</div>
) : (
<p className="mt-2 text-sm text-grayScale-500">No options available.</p>
)}
</div>
)}
{question.tips && (
<div className="rounded-lg border border-amber-100 bg-amber-50 p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-amber-500">💡 Tips</p>
<p className="mt-2 text-sm leading-relaxed text-amber-700">{question.tips}</p>
</div>
)}
</CardContent>
)}
</Card>
))}
</div>
))}
{totalQuestions > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-grayScale-200 bg-white px-4 py-3 text-sm text-grayScale-500">
<div className="flex flex-wrap items-center gap-2">
<span>
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))} ({totalQuestions}{" "}
total)
</span>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Rows per page
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setCurrentPage(1)
void fetchQuestions(1)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
</div>
{totalQuestions > pageSize ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => void fetchQuestions(currentPage - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage >= Math.ceil(totalQuestions / pageSize)}
onClick={() => void fetchQuestions(currentPage + 1)}
>
Next
</Button>
</div>
) : null}
</div>
)}
</div>
)}
{/* Delete Modal */}
{showDeleteModal && questionToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm rounded-xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Delete Question</h2>
<button
onClick={() => setShowDeleteModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-6 py-6">
<p className="text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete this question? This action cannot be undone.
</p>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setShowDeleteModal(false)} disabled={deleting}>
Cancel
</Button>
<Button className="bg-red-500 hover:bg-red-600" onClick={handleConfirmDelete} disabled={deleting}>
{deleting ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
</div>
)}
{/* Add Modal */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Add New Question</h2>
<button
onClick={() => setShowAddModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-5 px-6 py-6">
<div className="rounded-xl border-l-2 border-l-brand-500 border border-grayScale-200 p-4">
<div className="space-y-5">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Question Text
</label>
<Textarea
value={draft.questionText}
onChange={(e) => setDraft((prev) => ({ ...prev, questionText: e.target.value }))}
placeholder="Enter your question..."
rows={2}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Type</label>
<Select value={draft.questionType} onChange={(e) => handleDraftTypeChange(e.target.value as QuestionType)}>
<option value="MCQ">Multiple Choice</option>
<option value="TRUE_FALSE">True/False</option>
<option value="SHORT">Short Answer</option>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Difficulty</label>
<Select
value={draft.difficultyLevel}
onChange={(e) =>
setDraft((prev) => ({ ...prev, difficultyLevel: e.target.value as DifficultyLevel }))
}
>
<option value="EASY">Easy</option>
<option value="MEDIUM">Medium</option>
<option value="HARD">Hard</option>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Points</label>
<Input
type="number"
min={1}
value={draft.points}
onChange={(e) =>
setDraft((prev) => ({ ...prev, points: Number(e.target.value) || 1 }))
}
/>
</div>
</div>
{draft.questionType === "MCQ" && (
<div className="space-y-3 rounded-lg bg-grayScale-50/50 p-4">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Options</label>
<div className="space-y-2.5">
{draft.options.map((option, index) => (
<div
key={index}
className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 transition-colors ${
option.isCorrect ? "border-green-200 bg-green-50/50" : "border-grayScale-200 bg-white"
}`}
>
<button
type="button"
onClick={() => setDraftCorrectOption(index)}
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200 ${
option.isCorrect
? "border-green-500 bg-green-500 text-white shadow-sm"
: "border-grayScale-300 hover:border-brand-400 hover:shadow-sm"
}`}
>
{option.isCorrect && <Check className="h-3 w-3" />}
</button>
<Input
value={option.text}
onChange={(e) => updateDraftOption(index, { text: e.target.value })}
placeholder={`Option ${index + 1}`}
className="flex-1 border-0 bg-transparent shadow-none focus:ring-0"
/>
{draft.options.length > 2 && (
<button
type="button"
onClick={() => removeDraftOption(index)}
className="rounded-lg p-1 text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500"
>
<X className="h-4 w-4" />
</button>
)}
</div>
))}
<button
type="button"
onClick={addDraftOption}
className="mt-1 flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<Plus className="h-4 w-4" />
Add Option
</button>
</div>
<p className="text-xs text-grayScale-400">Click the circle to mark the correct answer.</p>
</div>
)}
{draft.questionType === "TRUE_FALSE" && (
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Correct Answer
</label>
<div className="flex gap-3">
{["True", "False"].map((val, i) => (
<button
key={val}
type="button"
onClick={() =>
setDraft((prev) => ({
...prev,
options: [
{ text: "True", isCorrect: i === 0 },
{ text: "False", isCorrect: i === 1 },
],
}))
}
className={`flex-1 rounded-lg border-2 px-4 py-2.5 text-sm font-medium transition-colors ${
draft.options[i]?.isCorrect
? "border-green-500 bg-green-50 text-green-700"
: "border-grayScale-200 text-grayScale-600 hover:border-grayScale-300"
}`}
>
{val}
</button>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label>
<Input
value={draft.tips}
onChange={(e) => setDraft((prev) => ({ ...prev, tips: e.target.value }))}
placeholder="Helpful tip for the student"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Explanation (Optional)
</label>
<Input
value={draft.explanation}
onChange={(e) => setDraft((prev) => ({ ...prev, explanation: e.target.value }))}
placeholder="Why this is the correct answer"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Voice Prompt (Optional)
</label>
<Input
value={draft.questionVoicePrompt}
onChange={(e) => setDraft((prev) => ({ ...prev, questionVoicePrompt: e.target.value }))}
placeholder="Voice prompt text"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Sample Answer Voice Prompt (Optional)
</label>
<Input
value={draft.sampleAnswerVoicePrompt}
onChange={(e) => setDraft((prev) => ({ ...prev, sampleAnswerVoicePrompt: e.target.value }))}
placeholder="Sample answer voice prompt"
/>
</div>
</div>
</div>
</div>
{saveError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2">
<p className="text-sm font-medium text-red-600">{saveError}</p>
</div>
)}
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setShowAddModal(false)} disabled={saving}>
Cancel
</Button>
<Button
className="bg-brand-500 hover:bg-brand-600"
onClick={handleSaveNewQuestion}
disabled={saving || !isDraftValid()}
>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{showEditModal && questionToEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Edit Question</h2>
<button
onClick={() => setShowEditModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-5 px-6 py-6">
<div className="rounded-xl border-l-2 border-l-brand-500 border border-grayScale-200 p-4">
<div className="space-y-5">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Question Text
</label>
<Textarea
value={draft.questionText}
onChange={(e) => setDraft((prev) => ({ ...prev, questionText: e.target.value }))}
placeholder="Enter your question..."
rows={2}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Type</label>
<Select value={draft.questionType} onChange={(e) => handleDraftTypeChange(e.target.value as QuestionType)}>
<option value="MCQ">Multiple Choice</option>
<option value="TRUE_FALSE">True/False</option>
<option value="SHORT">Short Answer</option>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Difficulty</label>
<Select
value={draft.difficultyLevel}
onChange={(e) =>
setDraft((prev) => ({ ...prev, difficultyLevel: e.target.value as DifficultyLevel }))
}
>
<option value="EASY">Easy</option>
<option value="MEDIUM">Medium</option>
<option value="HARD">Hard</option>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Points</label>
<Input
type="number"
min={1}
value={draft.points}
onChange={(e) =>
setDraft((prev) => ({ ...prev, points: Number(e.target.value) || 1 }))
}
/>
</div>
</div>
{draft.questionType === "MCQ" && (
<div className="space-y-3 rounded-lg bg-grayScale-50/50 p-4">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Options</label>
<div className="space-y-2.5">
{draft.options.map((option, index) => (
<div
key={index}
className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 transition-colors ${
option.isCorrect ? "border-green-200 bg-green-50/50" : "border-grayScale-200 bg-white"
}`}
>
<button
type="button"
onClick={() => setDraftCorrectOption(index)}
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200 ${
option.isCorrect
? "border-green-500 bg-green-500 text-white shadow-sm"
: "border-grayScale-300 hover:border-brand-400 hover:shadow-sm"
}`}
>
{option.isCorrect && <Check className="h-3 w-3" />}
</button>
<Input
value={option.text}
onChange={(e) => updateDraftOption(index, { text: e.target.value })}
placeholder={`Option ${index + 1}`}
className="flex-1 border-0 bg-transparent shadow-none focus:ring-0"
/>
{draft.options.length > 2 && (
<button
type="button"
onClick={() => removeDraftOption(index)}
className="rounded-lg p-1 text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500"
>
<X className="h-4 w-4" />
</button>
)}
</div>
))}
<button
type="button"
onClick={addDraftOption}
className="mt-1 flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<Plus className="h-4 w-4" />
Add Option
</button>
</div>
<p className="text-xs text-grayScale-400">Click the circle to mark the correct answer.</p>
</div>
)}
{draft.questionType === "TRUE_FALSE" && (
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Correct Answer
</label>
<div className="flex gap-3">
{["True", "False"].map((val, i) => (
<button
key={val}
type="button"
onClick={() =>
setDraft((prev) => ({
...prev,
options: [
{ text: "True", isCorrect: i === 0 },
{ text: "False", isCorrect: i === 1 },
],
}))
}
className={`flex-1 rounded-lg border-2 px-4 py-2.5 text-sm font-medium transition-colors ${
draft.options[i]?.isCorrect
? "border-green-500 bg-green-50 text-green-700"
: "border-grayScale-200 text-grayScale-600 hover:border-grayScale-300"
}`}
>
{val}
</button>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label>
<Input
value={draft.tips}
onChange={(e) => setDraft((prev) => ({ ...prev, tips: e.target.value }))}
placeholder="Helpful tip for the student"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Explanation (Optional)
</label>
<Input
value={draft.explanation}
onChange={(e) => setDraft((prev) => ({ ...prev, explanation: e.target.value }))}
placeholder="Why this is the correct answer"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Voice Prompt (Optional)
</label>
<Input
value={draft.questionVoicePrompt}
onChange={(e) => setDraft((prev) => ({ ...prev, questionVoicePrompt: e.target.value }))}
placeholder="Voice prompt text"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Sample Answer Voice Prompt (Optional)
</label>
<Input
value={draft.sampleAnswerVoicePrompt}
onChange={(e) => setDraft((prev) => ({ ...prev, sampleAnswerVoicePrompt: e.target.value }))}
placeholder="Sample answer voice prompt"
/>
</div>
</div>
</div>
</div>
{saveError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2">
<p className="text-sm font-medium text-red-600">{saveError}</p>
</div>
)}
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setShowEditModal(false)} disabled={saving}>
Cancel
</Button>
<Button
className="bg-brand-500 hover:bg-brand-600"
onClick={handleSaveEditQuestion}
disabled={saving || !isDraftValid()}
>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
</div>
)}
</div>
)
}