speaking section partly integration + more table filters + practice and question pages fixes for real data

This commit is contained in:
Yared Yemane 2026-03-10 08:12:40 -07:00
parent 31912d2e58
commit e2c61385ae
12 changed files with 2482 additions and 748 deletions

View File

@ -15,7 +15,6 @@ import type {
CreatePracticeRequest,
UpdatePracticeRequest,
UpdatePracticeStatusRequest,
GetPracticeQuestionsResponse,
CreatePracticeQuestionRequest,
UpdatePracticeQuestionRequest,
GetProgramsResponse,
@ -31,11 +30,17 @@ import type {
UpdateModuleRequest,
UpdateModuleStatusRequest,
GetQuestionSetsResponse,
GetQuestionSetsParams,
GetQuestionSetDetailResponse,
GetQuestionSetQuestionsResponse,
CreateQuestionSetRequest,
CreateQuestionSetResponse,
AddQuestionToSetRequest,
CreateQuestionRequest,
CreateQuestionResponse,
GetQuestionDetailResponse,
GetQuestionsParams,
GetQuestionsResponse,
CreateVimeoVideoRequest,
CreateCourseCategoryRequest,
GetSubCoursePrerequisitesResponse,
@ -119,7 +124,7 @@ export const deletePractice = (practiceId: number) =>
// Practice Questions APIs
export const getPracticeQuestions = (practiceId: number) =>
http.get<GetPracticeQuestionsResponse>(`/course-management/practices/${practiceId}/questions`)
http.get<GetQuestionSetQuestionsResponse>(`/question-sets/${practiceId}/questions`)
export const createPracticeQuestion = (data: CreatePracticeQuestionRequest) =>
http.post("/course-management/practice-questions", data)
@ -187,11 +192,20 @@ export const getPracticesByModule = (moduleId: number) =>
http.get<GetPracticesResponse>(`/course-management/modules/${moduleId}/practices`)
// Question Sets API
export const getQuestionSets = (params?: GetQuestionSetsParams) =>
http.get<GetQuestionSetsResponse>("/question-sets", { params })
export const getQuestionSetsByOwner = (ownerType: string, ownerId: number) =>
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
params: { owner_type: ownerType, owner_id: ownerId },
})
export const getQuestionSetById = (questionSetId: number) =>
http.get<GetQuestionSetDetailResponse>(`/question-sets/${questionSetId}`)
export const getQuestionSetQuestions = (questionSetId: number) =>
http.get<GetQuestionSetQuestionsResponse>(`/question-sets/${questionSetId}/questions`)
export const createQuestionSet = (data: CreateQuestionSetRequest) =>
http.post<CreateQuestionSetResponse>("/question-sets", data)
@ -201,6 +215,18 @@ export const addQuestionToSet = (questionSetId: number, data: AddQuestionToSetRe
export const createQuestion = (data: CreateQuestionRequest) =>
http.post<CreateQuestionResponse>("/questions", data)
export const getQuestions = (params: GetQuestionsParams) =>
http.get<GetQuestionsResponse>("/questions", { params })
export const getQuestionById = (questionId: number) =>
http.get<GetQuestionDetailResponse>(`/questions/${questionId}`)
export const deleteQuestion = (questionId: number) =>
http.delete(`/questions/${questionId}`)
export const updateQuestion = (questionId: number, data: CreateQuestionRequest) =>
http.put(`/questions/${questionId}`, data)
export const deleteQuestionSet = (questionSetId: number) =>
http.delete(`/question-sets/${questionSetId}`)

View File

@ -1,5 +1,13 @@
import http from "./http"
import type { LearnerCourseProgressResponse } from "../types/progress.types"
import type {
LearnerCourseProgressResponse,
LearnerCourseProgressSummaryResponse,
} from "../types/progress.types"
export const getAdminLearnerCourseProgress = (userId: number, courseId: number) =>
http.get<LearnerCourseProgressResponse>(`/admin/users/${userId}/progress/courses/${courseId}`)
export const getAdminLearnerCourseProgressSummary = (userId: number, courseId: number) =>
http.get<LearnerCourseProgressSummaryResponse>(
`/admin/users/${userId}/progress/courses/${courseId}/summary`,
)

View File

@ -1,4 +1,4 @@
import { useState } from "react"
import { useEffect, useState } from "react"
import { useNavigate, useParams } from "react-router-dom"
import { ArrowLeft, Plus, X } from "lucide-react"
import { toast } from "sonner"
@ -7,30 +7,41 @@ import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/ca
import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select"
import { createQuestion, getQuestionById, updateQuestion } from "../../api/courses.api"
type QuestionType = "multiple-choice" | "short-answer" | "true-false"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
type Difficulty = "EASY" | "MEDIUM" | "HARD"
type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE"
interface Question {
id: string
id?: number
question: string
type: QuestionType
options: string[]
correctAnswer: string
points: number
category?: string
difficulty?: string
difficulty: Difficulty
status: QuestionStatus
tips: string
explanation: string
voicePrompt: string
sampleAnswerVoicePrompt: string
audioCorrectAnswerText: string
}
// Mock data for editing
const mockQuestion: Question = {
id: "1",
const initialForm: Question = {
question: "",
type: "multiple-choice",
type: "MCQ",
options: ["", "", "", ""],
correctAnswer: "",
points: 10,
category: "",
difficulty: "",
points: 1,
difficulty: "EASY",
status: "PUBLISHED",
tips: "",
explanation: "",
voicePrompt: "",
sampleAnswerVoicePrompt: "",
audioCorrectAnswerText: "",
}
export function AddQuestionPage() {
@ -38,36 +49,83 @@ export function AddQuestionPage() {
const { id } = useParams<{ id?: string }>()
const isEditing = !!id
const [formData, setFormData] = useState<Question>(
isEditing
? mockQuestion // In a real app, fetch the question by id
: {
id: Date.now().toString(),
question: "",
type: "multiple-choice",
options: ["", "", "", ""],
correctAnswer: "",
points: 10,
category: "",
difficulty: "",
},
)
const [formData, setFormData] = useState<Question>(initialForm)
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
const loadQuestion = async () => {
if (!isEditing || !id) return
setLoading(true)
try {
const res = await getQuestionById(Number(id))
const q = res.data.data
const mappedType: QuestionType =
q.question_type === "MCQ" ||
q.question_type === "TRUE_FALSE" ||
q.question_type === "SHORT_ANSWER" ||
q.question_type === "AUDIO"
? q.question_type
: "MCQ"
const shortAnswer = Array.isArray(q.short_answers) && q.short_answers.length > 0
? typeof q.short_answers[0] === "string"
? String(q.short_answers[0] || "")
: String((q.short_answers[0] as { acceptable_answer?: string }).acceptable_answer || "")
: ""
setFormData({
id: q.id,
question: q.question_text || "",
type: mappedType,
options: (q.options ?? [])
.slice()
.sort((a, b) => a.option_order - b.option_order)
.map((o) => o.option_text) || ["", "", "", ""],
correctAnswer:
mappedType === "SHORT_ANSWER"
? shortAnswer
: mappedType === "AUDIO"
? q.audio_correct_answer_text || ""
: (q.options ?? []).find((o) => o.is_correct)?.option_text || "",
points: q.points ?? 1,
difficulty:
q.difficulty_level === "EASY" || q.difficulty_level === "MEDIUM" || q.difficulty_level === "HARD"
? q.difficulty_level
: "EASY",
status:
q.status === "DRAFT" || q.status === "PUBLISHED" || q.status === "INACTIVE"
? q.status
: "PUBLISHED",
tips: q.tips || "",
explanation: q.explanation || "",
voicePrompt: q.voice_prompt || "",
sampleAnswerVoicePrompt: q.sample_answer_voice_prompt || "",
audioCorrectAnswerText: q.audio_correct_answer_text || "",
})
} catch (error) {
console.error("Failed to load question:", error)
toast.error("Failed to load question details")
} finally {
setLoading(false)
}
}
loadQuestion()
}, [isEditing, id])
const handleTypeChange = (type: QuestionType) => {
setFormData((prev) => {
if (type === "true-false") {
if (type === "TRUE_FALSE") {
return {
...prev,
type,
options: ["True", "False"],
correctAnswer: prev.correctAnswer === "True" || prev.correctAnswer === "False" ? prev.correctAnswer : "",
}
} else if (type === "short-answer") {
} else if (type === "SHORT_ANSWER" || type === "AUDIO") {
return {
...prev,
type,
options: [],
correctAnswer: "",
correctAnswer: type === "AUDIO" ? prev.audioCorrectAnswerText : prev.correctAnswer,
}
} else {
return {
@ -101,7 +159,7 @@ export function AddQuestionPage() {
}))
}
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Validation
@ -112,14 +170,14 @@ export function AddQuestionPage() {
return
}
if (formData.type === "multiple-choice" || formData.type === "true-false") {
if (formData.type === "MCQ" || formData.type === "TRUE_FALSE") {
if (!formData.correctAnswer) {
toast.error("Missing correct answer", {
description: "Select the correct answer for this question.",
})
return
}
if (formData.type === "multiple-choice") {
if (formData.type === "MCQ") {
const hasEmptyOptions = formData.options.some((opt) => !opt.trim())
if (hasEmptyOptions) {
toast.error("Incomplete options", {
@ -128,23 +186,74 @@ export function AddQuestionPage() {
return
}
}
} else if (formData.type === "short-answer") {
} else if (formData.type === "SHORT_ANSWER") {
if (!formData.correctAnswer.trim()) {
toast.error("Missing correct answer", {
description: "Enter the expected correct answer.",
})
return
}
} else if (formData.type === "AUDIO") {
if (!formData.voicePrompt.trim() || !formData.sampleAnswerVoicePrompt.trim() || !formData.audioCorrectAnswerText.trim()) {
toast.error("Missing audio fields", {
description: "Voice prompt, sample answer voice prompt, and audio correct answer text are required for AUDIO questions.",
})
return
}
}
// In a real app, save the question here
console.log("Saving question:", formData)
toast.success(isEditing ? "Question updated" : "Question created", {
description: isEditing
? "The question has been updated successfully."
: "Your new question has been created.",
})
navigate("/content/questions")
setSubmitting(true)
try {
const optionsPayload =
formData.type === "MCQ" || formData.type === "TRUE_FALSE"
? formData.options
.filter((o) => o.trim())
.map((optionText, index) => ({
option_text: optionText.trim(),
option_order: index + 1,
is_correct: optionText === formData.correctAnswer,
}))
: undefined
const shortAnswersPayload =
formData.type === "SHORT_ANSWER"
? [
{ acceptable_answer: formData.correctAnswer.trim(), match_type: "EXACT" as const },
{ acceptable_answer: formData.correctAnswer.trim(), match_type: "CASE_INSENSITIVE" as const },
]
: undefined
const payload = {
question_text: formData.question,
question_type: formData.type,
status: formData.status,
difficulty_level: formData.difficulty,
points: formData.points,
tips: formData.tips || undefined,
explanation: formData.explanation || undefined,
options: optionsPayload,
short_answers: shortAnswersPayload,
voice_prompt: formData.type === "AUDIO" ? formData.voicePrompt : formData.voicePrompt || undefined,
sample_answer_voice_prompt:
formData.type === "AUDIO" ? formData.sampleAnswerVoicePrompt : formData.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text:
formData.type === "AUDIO" ? formData.audioCorrectAnswerText : undefined,
}
if (isEditing && id) {
await updateQuestion(Number(id), payload)
} else {
await createQuestion(payload)
}
toast.success(isEditing ? "Question updated" : "Question created", {
description: isEditing
? "The question has been updated successfully."
: "Your new question has been created.",
})
navigate("/content/questions")
} catch (error) {
console.error("Failed to save question:", error)
toast.error("Failed to save question")
} finally {
setSubmitting(false)
}
}
return (
@ -170,6 +279,11 @@ export function AddQuestionPage() {
</div>
<div className="max-w-3xl mx-auto">
{loading && (
<Card className="mb-4 border border-grayScale-200">
<CardContent className="py-4 text-sm text-grayScale-500">Loading question details...</CardContent>
</Card>
)}
<form onSubmit={handleSubmit}>
<Card className="shadow-sm border border-grayScale-100 rounded-xl">
<CardHeader className="pb-2">
@ -185,9 +299,10 @@ export function AddQuestionPage() {
value={formData.type}
onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
>
<option value="multiple-choice">Multiple Choice</option>
<option value="short-answer">Short Answer</option>
<option value="true-false">True/False</option>
<option value="MCQ">Multiple Choice</option>
<option value="TRUE_FALSE">True/False</option>
<option value="SHORT_ANSWER">Short Answer</option>
<option value="AUDIO">Audio</option>
</Select>
</div>
@ -209,7 +324,7 @@ export function AddQuestionPage() {
</div>
{/* Options for Multiple Choice */}
{(formData.type === "multiple-choice" || formData.type === "true-false") && (
{(formData.type === "MCQ" || formData.type === "TRUE_FALSE") && (
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Options
@ -224,10 +339,10 @@ export function AddQuestionPage() {
value={option}
onChange={(e) => handleOptionChange(index, e.target.value)}
placeholder={`Option ${index + 1}`}
disabled={formData.type === "true-false"}
disabled={formData.type === "TRUE_FALSE"}
required
/>
{formData.type === "multiple-choice" && formData.options.length > 2 && (
{formData.type === "MCQ" && formData.options.length > 2 && (
<Button
type="button"
variant="ghost"
@ -240,7 +355,7 @@ export function AddQuestionPage() {
)}
</div>
))}
{formData.type === "multiple-choice" && (
{formData.type === "MCQ" && (
<Button type="button" variant="outline" onClick={addOption} className="w-full mt-1 border-dashed border-grayScale-200 text-grayScale-400 hover:text-brand-500 hover:border-brand-500/30">
<Plus className="h-4 w-4" />
Add Option
@ -255,9 +370,9 @@ export function AddQuestionPage() {
{/* Correct Answer */}
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Correct Answer
{formData.type === "AUDIO" ? "Audio Correct Answer Text" : "Correct Answer"}
</label>
{formData.type === "multiple-choice" || formData.type === "true-false" ? (
{formData.type === "MCQ" || formData.type === "TRUE_FALSE" ? (
<Select
value={formData.correctAnswer}
onChange={(e) =>
@ -274,10 +389,14 @@ export function AddQuestionPage() {
</Select>
) : (
<Textarea
placeholder="Enter the correct answer..."
value={formData.correctAnswer}
placeholder={formData.type === "AUDIO" ? "Enter audio correct answer text..." : "Enter the correct answer..."}
value={formData.type === "AUDIO" ? formData.audioCorrectAnswerText : formData.correctAnswer}
onChange={(e) =>
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
setFormData((prev) =>
formData.type === "AUDIO"
? { ...prev, audioCorrectAnswerText: e.target.value }
: { ...prev, correctAnswer: e.target.value },
)
}
rows={2}
required
@ -300,7 +419,7 @@ export function AddQuestionPage() {
min="1"
value={formData.points}
onChange={(e) =>
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 0 }))
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 1 }))
}
required
/>
@ -312,27 +431,74 @@ export function AddQuestionPage() {
Difficulty (Optional)
</label>
<Select
value={formData.difficulty || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value }))}
value={formData.difficulty}
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value as Difficulty }))}
>
<option value="">Select difficulty</option>
<option value="Easy">Easy</option>
<option value="Medium">Medium</option>
<option value="Hard">Hard</option>
<option value="EASY">Easy</option>
<option value="MEDIUM">Medium</option>
<option value="HARD">Hard</option>
</Select>
</div>
</div>
{/* Category */}
{/* Status */}
<div>
<label htmlFor="category" className="mb-1.5 block text-sm font-medium text-grayScale-500">
Category (Optional)
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Status
</label>
<Select
value={formData.status}
onChange={(e) => setFormData((prev) => ({ ...prev, status: e.target.value as QuestionStatus }))}
>
<option value="DRAFT">Draft</option>
<option value="PUBLISHED">Published</option>
<option value="INACTIVE">Inactive</option>
</Select>
</div>
{(formData.type === "AUDIO" || formData.type === "SHORT_ANSWER") && (
<>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
</label>
<Textarea
value={formData.voicePrompt}
onChange={(e) => setFormData((prev) => ({ ...prev, voicePrompt: e.target.value }))}
rows={2}
placeholder="Please say your answer..."
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Sample Answer Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
</label>
<Textarea
value={formData.sampleAnswerVoicePrompt}
onChange={(e) => setFormData((prev) => ({ ...prev, sampleAnswerVoicePrompt: e.target.value }))}
rows={2}
placeholder="Sample spoken answer..."
/>
</div>
</>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Tips (Optional)</label>
<Input
id="category"
placeholder="e.g., Programming, Geography"
value={formData.category || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, category: e.target.value }))}
value={formData.tips}
onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))}
placeholder="Helpful tip for learners"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Explanation (Optional)</label>
<Textarea
value={formData.explanation}
onChange={(e) => setFormData((prev) => ({ ...prev, explanation: e.target.value }))}
rows={2}
placeholder="Explain why the answer is correct"
/>
</div>
@ -341,7 +507,7 @@ export function AddQuestionPage() {
<Button type="button" variant="outline" onClick={() => navigate("/content/questions")} className="w-full sm:w-auto hover:bg-grayScale-50">
Cancel
</Button>
<Button type="submit" className="bg-brand-500 hover:bg-brand-600 text-white w-full sm:w-auto shadow-sm hover:shadow-md transition-all">
<Button type="submit" disabled={submitting || loading} className="bg-brand-500 hover:bg-brand-600 text-white w-full sm:w-auto shadow-sm hover:shadow-md transition-all">
{isEditing ? "Update Question" : "Create Question"}
</Button>
</div>

View File

@ -1,299 +1,267 @@
import { useState } from "react"
import { Plus, Edit, Trash2 } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { RefreshCw } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { Card } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../../components/ui/dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table"
import { Badge } from "../../components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../../components/ui/dialog"
import { getQuestionSetById, getQuestionSets } from "../../api/courses.api"
import type { QuestionSet, QuestionSetDetail } from "../../types/course.types"
const mockLeaders = [
{ id: "1", name: "John Doe", role: "CEO" },
{ id: "2", name: "Jane Smith", role: "COO" },
]
const mockMembers = [
{ id: "1", name: "John Doe", role: "Member" },
{ id: "2", name: "Jane Smith", role: "Member" },
]
const statusColor: Record<string, string> = {
PUBLISHED: "bg-green-100 text-green-700",
DRAFT: "bg-amber-100 text-amber-700",
ARCHIVED: "bg-grayScale-200 text-grayScale-600",
}
export function PracticeDetailsPage() {
const [isMemberModalOpen, setIsMemberModalOpen] = useState(false)
const [isLeaderModalOpen, setIsLeaderModalOpen] = useState(false)
const [memberName, setMemberName] = useState("")
const [memberRole, setMemberRole] = useState("")
const [leaderName, setLeaderName] = useState("")
const [leaderRole, setLeaderRole] = useState("")
const [practices, setPractices] = useState<QuestionSet[]>([])
const [selectedPracticeId, setSelectedPracticeId] = useState<number | null>(null)
const [selectedPracticeDetail, setSelectedPracticeDetail] = useState<QuestionSetDetail | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [loadingList, setLoadingList] = useState(false)
const [loadingDetail, setLoadingDetail] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [ownerTypeFilter, setOwnerTypeFilter] = useState("all")
const [formData, setFormData] = useState({
name: "",
description: "",
type: "",
street: "",
city: "",
state: "",
zipCode: "",
})
const fetchPractices = useCallback(async () => {
setLoadingList(true)
try {
const res = await getQuestionSets({ set_type: "PRACTICE" })
const payload = res.data?.data as unknown
let sets: QuestionSet[] = []
if (Array.isArray(payload)) {
sets = payload as QuestionSet[]
} else if (
payload &&
typeof payload === "object" &&
Array.isArray((payload as { question_sets?: unknown[] }).question_sets)
) {
sets = (payload as { question_sets: QuestionSet[] }).question_sets
}
setPractices(sets)
if (sets.length > 0) {
setSelectedPracticeId((prev) => prev ?? sets[0].id)
} else {
setSelectedPracticeId(null)
setSelectedPracticeDetail(null)
}
} catch (error) {
console.error("Failed to fetch practices:", error)
setPractices([])
setSelectedPracticeId(null)
setSelectedPracticeDetail(null)
} finally {
setLoadingList(false)
}
}, [])
const handleAddMember = () => {
console.log("Add member:", { memberName, memberRole })
setIsMemberModalOpen(false)
setMemberName("")
setMemberRole("")
}
const fetchPracticeDetail = useCallback(async (practiceId: number) => {
setLoadingDetail(true)
try {
const res = await getQuestionSetById(practiceId)
setSelectedPracticeDetail(res.data?.data ?? null)
} catch (error) {
console.error("Failed to fetch practice detail:", error)
setSelectedPracticeDetail(null)
} finally {
setLoadingDetail(false)
}
}, [])
const handleAddLeader = () => {
console.log("Add leader:", { leaderName, leaderRole })
setIsLeaderModalOpen(false)
setLeaderName("")
setLeaderRole("")
}
useEffect(() => {
fetchPractices()
}, [fetchPractices])
useEffect(() => {
if (selectedPracticeId) {
fetchPracticeDetail(selectedPracticeId)
}
}, [selectedPracticeId, fetchPracticeDetail])
const filteredPractices = useMemo(() => {
return practices.filter((practice) => {
const matchesSearch =
!searchQuery.trim() ||
practice.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
(practice.description || "").toLowerCase().includes(searchQuery.toLowerCase()) ||
String(practice.id).includes(searchQuery) ||
String(practice.owner_id).includes(searchQuery)
const matchesStatus = statusFilter === "all" || practice.status === statusFilter
const matchesOwnerType = ownerTypeFilter === "all" || practice.owner_type === ownerTypeFilter
return matchesSearch && matchesStatus && matchesOwnerType
})
}, [practices, searchQuery, statusFilter, ownerTypeFilter])
const totalCount = useMemo(() => filteredPractices.length, [filteredPractices])
return (
<div className="space-y-8">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Practice Management</h1>
<p className="mt-1 text-sm text-grayScale-400">Manage your practice details, leadership, and members</p>
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Practice Management</h1>
<p className="mt-1 text-sm text-grayScale-400">
Browse all practice question sets and view their details.
</p>
</div>
<Button variant="outline" onClick={fetchPractices} disabled={loadingList}>
<RefreshCw className={`h-4 w-4 ${loadingList ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
{/* Practice Leadership */}
<Card className="border-grayScale-200 p-6 shadow-sm">
<div className="mb-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<h2 className="text-lg font-semibold tracking-tight text-grayScale-600">Practice Leadership</h2>
<Button
size="sm"
onClick={() => setIsLeaderModalOpen(true)}
className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors w-full sm:w-auto"
>
<Plus className="h-4 w-4" />
Add New Leader
</Button>
</div>
<div className="space-y-2">
{mockLeaders.map((leader) => (
<div
key={leader.id}
className="group flex items-center justify-between rounded-xl border border-grayScale-200 p-3.5 transition-all hover:border-grayScale-300 hover:bg-grayScale-50/50 hover:shadow-sm"
>
<div className="flex items-center gap-3">
<div className="grid h-9 w-9 place-items-center rounded-full bg-brand-100 text-sm font-semibold text-brand-600">
{leader.name[0]}
</div>
<div>
<p className="font-medium text-grayScale-600">{leader.name}</p>
<p className="text-xs text-grayScale-400">{leader.role}</p>
</div>
</div>
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-grayScale-600">
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</Card>
{/* Practice Details */}
<Card className="border-grayScale-200 p-6 shadow-sm">
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">Practice Details</h2>
<div className="space-y-5">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Practice Name
</label>
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-4">
<CardTitle className="text-base font-semibold text-grayScale-600">
Practices ({totalCount})
</CardTitle>
</CardHeader>
<CardContent className="pt-5">
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex-1">
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Enter practice name"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by title, description, practice ID, or owner ID..."
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Practice Description
</label>
<Textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Enter practice description"
rows={3}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Practice Type
</label>
<Select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
>
<option value="">Select practice type</option>
<option value="online">Online</option>
<option value="offline">Offline</option>
<option value="hybrid">Hybrid</option>
<div className="flex flex-wrap items-center gap-2">
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">All Statuses</option>
<option value="PUBLISHED">PUBLISHED</option>
<option value="DRAFT">DRAFT</option>
<option value="ARCHIVED">ARCHIVED</option>
</Select>
<Select value={ownerTypeFilter} onChange={(e) => setOwnerTypeFilter(e.target.value)}>
<option value="all">All Owner Types</option>
<option value="SUB_COURSE">SUB_COURSE</option>
<option value="COURSE">COURSE</option>
</Select>
<Button
variant="outline"
onClick={() => {
setSearchQuery("")
setStatusFilter("all")
setOwnerTypeFilter("all")
}}
>
Clear
</Button>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Practice Address
</label>
<div className="space-y-2">
<Input
value={formData.street}
onChange={(e) => setFormData({ ...formData, street: e.target.value })}
placeholder="Street"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Input
value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
placeholder="City"
/>
<Input
value={formData.state}
onChange={(e) => setFormData({ ...formData, state: e.target.value })}
placeholder="State"
/>
</div>
<Input
value={formData.zipCode}
onChange={(e) => setFormData({ ...formData, zipCode: e.target.value })}
placeholder="Zip Code"
/>
</div>
</div>
<Button className="w-full bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
Save Changes
</Button>
</div>
</Card>
</div>
{/* Practice Members */}
<Card className="border-grayScale-200 p-6 shadow-sm">
<div className="mb-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<h2 className="text-lg font-semibold tracking-tight text-grayScale-600">Practice Members</h2>
<Button
size="sm"
onClick={() => setIsMemberModalOpen(true)}
className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors w-full sm:w-auto"
>
<Plus className="h-4 w-4" />
Add New Member
</Button>
</div>
<div className="space-y-2">
{mockMembers.map((member) => (
<div
key={member.id}
className="group flex items-center justify-between rounded-xl border border-grayScale-200 p-3.5 transition-all hover:border-grayScale-300 hover:bg-grayScale-50/50 hover:shadow-sm"
>
<div className="flex items-center gap-3">
<div className="grid h-9 w-9 place-items-center rounded-full bg-brand-100 text-sm font-semibold text-brand-600">
{member.name[0]}
</div>
<div>
<p className="font-medium text-grayScale-600">{member.name}</p>
<p className="text-xs text-grayScale-400">{member.role}</p>
</div>
</div>
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-grayScale-600">
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</div>
{loadingList ? (
<div className="py-16 text-center text-sm text-grayScale-500">Loading practices...</div>
) : filteredPractices.length === 0 ? (
<div className="rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center text-sm text-grayScale-500">
No practice sets found.
</div>
))}
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
<Table>
<TableHeader>
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">Title</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">Owner</TableHead>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">Status</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPractices.map((practice, index) => (
<TableRow
key={practice.id}
onClick={() => {
setSelectedPracticeId(practice.id)
setDetailOpen(true)
}}
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
selectedPracticeId === practice.id
? "bg-brand-100/40"
: index % 2 === 0
? "bg-white"
: "bg-grayScale-100/50"
}`}
>
<TableCell className="max-w-md py-3.5">
<p className="truncate text-sm font-medium text-grayScale-700">{practice.title}</p>
<p className="mt-1 truncate text-xs text-grayScale-500">{practice.description || "—"}</p>
</TableCell>
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
{practice.owner_type} #{practice.owner_id}
</TableCell>
<TableCell className="py-3.5">
<Badge className={statusColor[practice.status] || "bg-grayScale-200 text-grayScale-600"}>
{practice.status}
</Badge>
</TableCell>
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
{practice.created_at}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Add Member Modal */}
<Dialog open={isMemberModalOpen} onOpenChange={setIsMemberModalOpen}>
<DialogContent className="sm:rounded-xl">
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add New Member</DialogTitle>
<DialogTitle>Practice Detail</DialogTitle>
</DialogHeader>
<div className="space-y-5 py-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Member Name
</label>
<Input
value={memberName}
onChange={(e) => setMemberName(e.target.value)}
placeholder="Enter member name"
/>
{!selectedPracticeId ? (
<p className="text-sm text-grayScale-500">Select a practice from the list to view details.</p>
) : loadingDetail ? (
<p className="text-sm text-grayScale-500">Loading detail...</p>
) : !selectedPracticeDetail ? (
<p className="text-sm text-grayScale-500">Failed to load practice detail.</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Title</p>
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.title}</p>
</div>
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Set Type</p>
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.set_type}</p>
</div>
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3 sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Description</p>
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.description || "—"}</p>
</div>
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Owner</p>
<p className="mt-1 text-sm text-grayScale-700">
{selectedPracticeDetail.owner_type} #{selectedPracticeDetail.owner_id}
</p>
</div>
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Status</p>
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.status}</p>
</div>
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Question Count</p>
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.question_count ?? 0}</p>
</div>
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Created At</p>
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.created_at}</p>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Member Role
</label>
<Input
value={memberRole}
onChange={(e) => setMemberRole(e.target.value)}
placeholder="Enter member role"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsMemberModalOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddMember} className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
Add Member
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Leader Modal */}
<Dialog open={isLeaderModalOpen} onOpenChange={setIsLeaderModalOpen}>
<DialogContent className="sm:rounded-xl">
<DialogHeader>
<DialogTitle>Add New Leader</DialogTitle>
</DialogHeader>
<div className="space-y-5 py-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Leader Name
</label>
<Input
value={leaderName}
onChange={(e) => setLeaderName(e.target.value)}
placeholder="Enter leader name"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Leader Role
</label>
<Input
value={leaderRole}
onChange={(e) => setLeaderRole(e.target.value)}
placeholder="Enter leader role"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsLeaderModalOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddLeader} className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
Add Leader
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,11 @@
import { useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router-dom"
import { Plus, Search, Edit, Trash2, HelpCircle } from "lucide-react"
import { Plus, Search, Edit, Trash2, HelpCircle, X } from "lucide-react"
import { Button } from "../../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select"
import { Textarea } from "../../components/ui/textarea"
import {
Table,
TableBody,
@ -14,117 +15,294 @@ import {
TableRow,
} from "../../components/ui/table"
import { Badge } from "../../components/ui/badge"
import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api"
import type { QuestionDetail } from "../../types/course.types"
type QuestionType = "multiple-choice" | "short-answer" | "true-false"
type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
type DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD"
type StatusFilter = "all" | "DRAFT" | "PUBLISHED" | "INACTIVE"
type QuestionTypeEdit = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
interface Question {
id: string
question: string
type: QuestionType
options: string[]
correctAnswer: string
points: number
category?: string
difficulty?: string
createdAt?: string
interface EditOption {
option_text: string
option_order: number
is_correct: boolean
}
// Mock data
const mockQuestions: Question[] = [
{
id: "1",
question: "What is the capital of France?",
type: "multiple-choice",
options: ["London", "Berlin", "Paris", "Madrid"],
correctAnswer: "Paris",
points: 10,
category: "Geography",
difficulty: "Easy",
createdAt: "2024-01-15",
},
{
id: "2",
question: "Explain the concept of React hooks in your own words.",
type: "short-answer",
options: [],
correctAnswer: "React hooks are functions that let you use state and other React features in functional components.",
points: 20,
category: "Programming",
difficulty: "Medium",
createdAt: "2024-01-16",
},
{
id: "3",
question: "JavaScript is a compiled language.",
type: "true-false",
options: ["True", "False"],
correctAnswer: "False",
points: 5,
category: "Programming",
difficulty: "Easy",
createdAt: "2024-01-17",
},
{
id: "4",
question: "Which of the following is a CSS preprocessor?",
type: "multiple-choice",
options: ["SASS", "HTML", "JavaScript", "Python"],
correctAnswer: "SASS",
points: 15,
category: "Web Development",
difficulty: "Medium",
createdAt: "2024-01-18",
},
{
id: "5",
question: "TypeScript is a superset of JavaScript.",
type: "true-false",
options: ["True", "False"],
correctAnswer: "True",
points: 10,
category: "Programming",
difficulty: "Easy",
createdAt: "2024-01-19",
},
]
const typeLabels: Record<QuestionType, string> = {
"multiple-choice": "Multiple Choice",
"short-answer": "Short Answer",
"true-false": "True/False",
const typeLabels: Record<string, string> = {
MCQ: "Multiple Choice",
TRUE_FALSE: "True/False",
SHORT_ANSWER: "Short Answer",
SHORT: "Short Answer",
AUDIO: "Audio",
}
const typeColors: Record<QuestionType, string> = {
"multiple-choice": "bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-200",
"short-answer": "bg-mint-100 text-green-700 ring-1 ring-inset ring-green-200",
"true-false": "bg-brand-100 text-brand-600 ring-1 ring-inset ring-brand-200",
const typeColors: Record<string, string> = {
MCQ: "bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-200",
TRUE_FALSE: "bg-brand-100 text-brand-600 ring-1 ring-inset ring-brand-200",
SHORT_ANSWER: "bg-mint-100 text-green-700 ring-1 ring-inset ring-green-200",
SHORT: "bg-mint-100 text-green-700 ring-1 ring-inset ring-green-200",
AUDIO: "bg-purple-100 text-purple-700 ring-1 ring-inset ring-purple-200",
}
export function QuestionsPage() {
const [questions, setQuestions] = useState<Question[]>(mockQuestions)
const [questions, setQuestions] = useState<QuestionDetail[]>([])
const [loading, setLoading] = useState(false)
const [deleting, setDeleting] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [typeFilter, setTypeFilter] = useState<string>("all")
const [categoryFilter, setCategoryFilter] = useState<string>("all")
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
const [typeFilter, setTypeFilter] = useState<QuestionTypeFilter>("all")
const [difficultyFilter, setDifficultyFilter] = useState<DifficultyFilter>("all")
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[]>([])
const [detailsOpen, setDetailsOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [activeQuestionId, setActiveQuestionId] = useState<number | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [detailData, setDetailData] = useState<QuestionDetail | null>(null)
const [savingEdit, setSavingEdit] = useState(false)
const [editQuestionText, setEditQuestionText] = useState("")
const [editQuestionType, setEditQuestionType] = useState<QuestionTypeEdit>("MCQ")
const [editDifficulty, setEditDifficulty] = useState("EASY")
const [editPoints, setEditPoints] = useState(1)
const [editStatus, setEditStatus] = useState("PUBLISHED")
const [editTips, setEditTips] = useState("")
const [editExplanation, setEditExplanation] = useState("")
const [editVoicePrompt, setEditVoicePrompt] = useState("")
const [editSampleAnswerVoicePrompt, setEditSampleAnswerVoicePrompt] = useState("")
const [editShortAnswer, setEditShortAnswer] = useState("")
const [editOptions, setEditOptions] = useState<EditOption[]>([
{ option_text: "", option_order: 1, is_correct: true },
{ option_text: "", option_order: 2, is_correct: false },
])
const filteredQuestions = questions.filter((q) => {
const matchesSearch = q.question.toLowerCase().includes(searchQuery.toLowerCase())
const matchesType = typeFilter === "all" || q.type === typeFilter
const matchesCategory = categoryFilter === "all" || q.category === categoryFilter
const matchesDifficulty = difficultyFilter === "all" || q.difficulty === difficultyFilter
const fetchQuestions = useCallback(async () => {
setLoading(true)
try {
const batchSize = 100
let nextOffset = 0
let allRows: QuestionDetail[] = []
let expectedTotal = Number.POSITIVE_INFINITY
return matchesSearch && matchesType && matchesCategory && matchesDifficulty
})
while (allRows.length < expectedTotal) {
const res = await getQuestions({
question_type: typeFilter === "all" ? undefined : typeFilter,
difficulty: difficultyFilter === "all" ? undefined : difficultyFilter,
status: statusFilter === "all" ? undefined : statusFilter,
limit: batchSize,
offset: nextOffset,
})
const categories = Array.from(new Set(questions.map((q) => q.category).filter(Boolean)))
const difficulties = Array.from(new Set(questions.map((q) => q.difficulty).filter(Boolean)))
const payload = res.data?.data as unknown
const meta = res.data?.metadata as { total_count?: number } | null | undefined
const handleDelete = (id: string) => {
if (window.confirm("Are you sure you want to delete this question?")) {
setQuestions(questions.filter((q) => q.id !== id))
let chunk: QuestionDetail[] = []
let chunkTotal: number | undefined
if (Array.isArray(payload)) {
chunk = payload as QuestionDetail[]
chunkTotal = meta?.total_count
} else if (
payload &&
typeof payload === "object" &&
Array.isArray((payload as { questions?: unknown[] }).questions)
) {
const data = payload as { questions: QuestionDetail[]; total_count?: number }
chunk = data.questions
chunkTotal = data.total_count ?? meta?.total_count
}
allRows = [...allRows, ...chunk]
if (typeof chunkTotal === "number" && Number.isFinite(chunkTotal)) {
expectedTotal = chunkTotal
}
if (chunk.length < batchSize) break
nextOffset += chunk.length
}
setQuestions(allRows)
} catch (error) {
console.error("Failed to fetch questions:", error)
setQuestions([])
} finally {
setLoading(false)
}
}, [typeFilter, difficultyFilter, statusFilter])
useEffect(() => {
fetchQuestions()
}, [fetchQuestions])
useEffect(() => {
setPage(1)
setSelectedIds([])
}, [searchQuery, pageSize, typeFilter, difficultyFilter, statusFilter])
const filteredQuestions = useMemo(() => {
if (!searchQuery.trim()) return questions
return questions.filter((q) =>
q.question_text.toLowerCase().includes(searchQuery.toLowerCase()),
)
}, [questions, searchQuery])
const paginatedQuestions = useMemo(() => {
const start = (page - 1) * pageSize
return filteredQuestions.slice(start, start + pageSize)
}, [filteredQuestions, page, pageSize])
const handleDeleteRequest = (ids: number[]) => {
setPendingDeleteIds(ids)
setDeleteDialogOpen(true)
}
const handleDeleteConfirm = async () => {
if (pendingDeleteIds.length === 0) return
setDeleting(true)
try {
await Promise.all(pendingDeleteIds.map((id) => deleteQuestion(id)))
setDeleteDialogOpen(false)
setPendingDeleteIds([])
setSelectedIds((prev) => prev.filter((id) => !pendingDeleteIds.includes(id)))
await fetchQuestions()
} catch (error) {
console.error("Failed to delete question(s):", error)
} finally {
setDeleting(false)
}
}
const openDetails = async (id: number) => {
setDetailsOpen(true)
setDetailLoading(true)
setDetailData(null)
try {
const res = await getQuestionById(id)
setDetailData(res.data.data)
} catch (error) {
console.error("Failed to fetch question details:", error)
} finally {
setDetailLoading(false)
}
}
const openEdit = async (id: number) => {
setEditOpen(true)
setDetailLoading(true)
setActiveQuestionId(id)
try {
const res = await getQuestionById(id)
const q = res.data.data
setDetailData(q)
setEditQuestionText(q.question_text || "")
setEditQuestionType((q.question_type as QuestionTypeEdit) || "MCQ")
setEditDifficulty((q.difficulty_level as string) || "EASY")
setEditPoints(q.points ?? 1)
setEditStatus(q.status || "PUBLISHED")
setEditTips(q.tips || "")
setEditExplanation(q.explanation || "")
setEditVoicePrompt(q.voice_prompt || "")
setEditSampleAnswerVoicePrompt(q.sample_answer_voice_prompt || "")
const incomingShort = Array.isArray(q.short_answers) && q.short_answers.length > 0
? typeof q.short_answers[0] === "string"
? String(q.short_answers[0] || "")
: String((q.short_answers[0] as { acceptable_answer?: string }).acceptable_answer || "")
: ""
setEditShortAnswer(incomingShort)
const mappedOptions =
(q.options ?? [])
.slice()
.sort((a, b) => a.option_order - b.option_order)
.map((opt) => ({
option_text: opt.option_text,
option_order: opt.option_order,
is_correct: opt.is_correct,
})) || []
setEditOptions(
mappedOptions.length > 0
? mappedOptions
: [
{ option_text: "", option_order: 1, is_correct: true },
{ option_text: "", option_order: 2, is_correct: false },
],
)
} catch (error) {
console.error("Failed to fetch question for edit:", error)
} finally {
setDetailLoading(false)
}
}
const saveEdit = async () => {
if (!activeQuestionId) return
setSavingEdit(true)
try {
const normalizedOptions = editOptions
.filter((o) => o.option_text.trim())
.map((o, idx) => ({
option_text: o.option_text.trim(),
option_order: idx + 1,
is_correct: o.is_correct,
}))
await updateQuestion(activeQuestionId, {
question_text: editQuestionText,
question_type: editQuestionType,
difficulty_level: editDifficulty,
points: editPoints,
status: editStatus,
tips: editTips || undefined,
explanation: editExplanation || undefined,
voice_prompt: editVoicePrompt || undefined,
sample_answer_voice_prompt: editSampleAnswerVoicePrompt || undefined,
options:
editQuestionType === "SHORT_ANSWER"
? undefined
: normalizedOptions,
short_answers:
editQuestionType === "SHORT_ANSWER"
? [
{ acceptable_answer: editShortAnswer, match_type: "EXACT" },
{ acceptable_answer: editShortAnswer, match_type: "CASE_INSENSITIVE" },
]
: undefined,
})
setEditOpen(false)
await fetchQuestions()
} catch (error) {
console.error("Failed to update question:", error)
} finally {
setSavingEdit(false)
}
}
const toggleOne = (id: number) => {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((selectedId) => selectedId !== id) : [...prev, id],
)
}
const currentPageIds = paginatedQuestions.map((q) => q.id)
const isAllCurrentPageSelected =
currentPageIds.length > 0 && currentPageIds.every((id) => selectedIds.includes(id))
const toggleSelectAllCurrentPage = () => {
setSelectedIds((prev) => {
if (isAllCurrentPageSelected) {
return prev.filter((id) => !currentPageIds.includes(id))
}
const merged = new Set([...prev, ...currentPageIds])
return Array.from(merged)
})
}
const totalCount = filteredQuestions.length
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const canGoPrev = page > 1
const canGoNext = page < totalPages
return (
<div className="space-y-8">
{/* Page Header */}
@ -137,12 +315,23 @@ export function QuestionsPage() {
Create and manage your question bank
</p>
</div>
<Link to="/content/questions/add" className="w-full sm:w-auto">
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
<Plus className="h-4 w-4" />
Add New Question
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button
variant="outline"
className="w-full sm:w-auto"
disabled={selectedIds.length === 0}
onClick={() => handleDeleteRequest(selectedIds)}
>
<Trash2 className="h-4 w-4" />
Delete Selected ({selectedIds.length})
</Button>
</Link>
<Link to="/content/questions/add" className="w-full sm:w-auto">
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
<Plus className="h-4 w-4" />
Add New Question
</Button>
</Link>
</div>
</div>
<Card className="shadow-soft">
@ -165,51 +354,78 @@ export function QuestionsPage() {
</div>
<div className="flex flex-wrap items-center gap-2">
<Select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
<Select
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value as QuestionTypeFilter)
}}
>
<option value="all">All Types</option>
<option value="multiple-choice">Multiple Choice</option>
<option value="short-answer">Short Answer</option>
<option value="true-false">True/False</option>
<option value="MCQ">Multiple Choice</option>
<option value="TRUE_FALSE">True/False</option>
<option value="SHORT_ANSWER">Short Answer</option>
<option value="AUDIO">Audio</option>
</Select>
<Select
value={difficultyFilter}
onChange={(e) => {
setDifficultyFilter(e.target.value as DifficultyFilter)
}}
>
<option value="all">All Difficulties</option>
<option value="EASY">Easy</option>
<option value="MEDIUM">Medium</option>
<option value="HARD">Hard</option>
</Select>
<Select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value as StatusFilter)
}}
>
<option value="all">All Statuses</option>
<option value="DRAFT">Draft</option>
<option value="PUBLISHED">Published</option>
<option value="INACTIVE">Inactive</option>
</Select>
<Select
value={String(pageSize)}
onChange={(e) => {
const next = Number(e.target.value)
setPageSize(next)
setPage(1)
}}
>
<option value="10">10 / page</option>
<option value="20">20 / page</option>
<option value="50">50 / page</option>
</Select>
{categories.length > 0 && (
<Select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)}>
<option value="all">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</Select>
)}
{difficulties.length > 0 && (
<Select
value={difficultyFilter}
onChange={(e) => setDifficultyFilter(e.target.value)}
>
<option value="all">All Difficulties</option>
{difficulties.map((diff) => (
<option key={diff} value={diff}>
{diff}
</option>
))}
</Select>
)}
</div>
</div>
{/* Results count */}
<div className="text-xs font-medium text-grayScale-400">
Showing {filteredQuestions.length} of {questions.length} questions
Showing {paginatedQuestions.length} of {totalCount} questions
</div>
{/* Questions Table */}
{filteredQuestions.length > 0 ? (
{loading ? (
<div className="flex items-center justify-center rounded-lg border border-grayScale-200 py-16">
<p className="text-sm text-grayScale-500">Loading questions...</p>
</div>
) : filteredQuestions.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
<Table>
<TableHeader>
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
<TableHead className="w-10 py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
<input
type="checkbox"
checked={isAllCurrentPageSelected}
onChange={toggleSelectAllCurrentPage}
aria-label="Select all questions on current page"
/>
</TableHead>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Question
</TableHead>
@ -217,10 +433,10 @@ export function QuestionsPage() {
Type
</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
Category
Difficulty
</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
Difficulty
Status
</TableHead>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Points
@ -231,65 +447,80 @@ export function QuestionsPage() {
</TableRow>
</TableHeader>
<TableBody>
{filteredQuestions.map((question, index) => (
{paginatedQuestions.map((question, index) => (
<TableRow
key={question.id}
className={`transition-colors hover:bg-brand-100/30 ${
onClick={() => openDetails(question.id)}
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/50"
}`}
>
<TableCell className="py-3.5">
<input
type="checkbox"
checked={selectedIds.includes(question.id)}
onClick={(e) => e.stopPropagation()}
onChange={() => toggleOne(question.id)}
aria-label={`Select question ${question.id}`}
/>
</TableCell>
<TableCell className="max-w-md py-3.5">
<div className="truncate text-sm font-medium text-grayScale-600">
{question.question}
{question.question_text}
</div>
{question.type === "multiple-choice" && question.options.length > 0 && (
{question.question_type === "MCQ" && (question.options?.length ?? 0) > 0 && (
<div className="mt-1 truncate text-xs text-grayScale-400">
Options: {question.options.join(", ")}
Options: {question.options?.map((opt) => opt.option_text).join(", ")}
</div>
)}
</TableCell>
<TableCell className="py-3.5">
<Badge className={`text-xs font-medium ${typeColors[question.type]}`}>
{typeLabels[question.type]}
<Badge className={`text-xs font-medium ${typeColors[question.question_type] || "bg-grayScale-100 text-grayScale-600"}`}>
{typeLabels[question.question_type] || question.question_type}
</Badge>
</TableCell>
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
{question.category || "—"}
</TableCell>
<TableCell className="hidden py-3.5 md:table-cell">
{question.difficulty && (
{question.difficulty_level && (
<Badge
variant={
question.difficulty === "Easy"
question.difficulty_level === "EASY"
? "default"
: question.difficulty === "Medium"
: question.difficulty_level === "MEDIUM"
? "secondary"
: "destructive"
}
>
{question.difficulty}
{question.difficulty_level}
</Badge>
)}
</TableCell>
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
{question.status || "—"}
</TableCell>
<TableCell className="py-3.5 text-sm font-semibold text-grayScale-600">
{question.points}
{question.points ?? 0}
</TableCell>
<TableCell className="py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
<Link to={`/content/questions/edit/${question.id}`}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-grayScale-400 hover:bg-brand-100/50 hover:text-brand-500"
>
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-grayScale-400 hover:bg-brand-100/50 hover:text-brand-500"
onClick={(e) => {
e.stopPropagation()
openEdit(question.id)
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-grayScale-400 hover:bg-red-50 hover:text-destructive"
onClick={() => handleDelete(question.id)}
onClick={(e) => {
e.stopPropagation()
handleDeleteRequest([question.id])
}}
>
<Trash2 className="h-4 w-4" />
</Button>
@ -314,8 +545,225 @@ export function QuestionsPage() {
</p>
</div>
)}
<div className="flex flex-col gap-3 border-t border-grayScale-200 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-grayScale-500">
Page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={!canGoPrev}
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={!canGoNext}
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
>
Next
</Button>
</div>
</div>
</CardContent>
</Card>
{deleteDialogOpen && (
<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-md 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 {pendingDeleteIds.length > 1 ? "Questions" : "Question"}
</h2>
<button
onClick={() => setDeleteDialogOpen(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{" "}
<span className="font-semibold text-grayScale-800">
{pendingDeleteIds.length} question{pendingDeleteIds.length > 1 ? "s" : ""}
</span>
? 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={() => setDeleteDialogOpen(false)} disabled={deleting}>
Cancel
</Button>
<Button className="bg-red-500 hover:bg-red-600" onClick={handleDeleteConfirm} disabled={deleting}>
{deleting ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
</div>
)}
{detailsOpen && (
<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-2xl 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">Question Details</h2>
<button
onClick={() => setDetailsOpen(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-4 px-6 py-6">
{detailLoading || !detailData ? (
<p className="text-sm text-grayScale-500">Loading details...</p>
) : (
<>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Question</p>
<p className="mt-1 text-sm text-grayScale-700">{detailData.question_text}</p>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<p><span className="font-medium">Type:</span> {typeLabels[detailData.question_type] || detailData.question_type}</p>
<p><span className="font-medium">Difficulty:</span> {detailData.difficulty_level || "—"}</p>
<p><span className="font-medium">Points:</span> {detailData.points ?? 0}</p>
<p><span className="font-medium">Status:</span> {detailData.status || "—"}</p>
</div>
{(detailData.options ?? []).length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Options</p>
<div className="mt-2 space-y-2">
{(detailData.options ?? [])
.slice()
.sort((a, b) => a.option_order - b.option_order)
.map((opt) => (
<div
key={`${opt.option_order}-${opt.option_text}`}
className={`rounded-md border px-3 py-2 text-sm ${
opt.is_correct ? "border-green-200 bg-green-50 text-green-700" : "border-grayScale-200 bg-grayScale-50 text-grayScale-600"
}`}
>
{opt.option_order}. {opt.option_text}
</div>
))}
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
)}
{editOpen && (
<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-2xl 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={() => setEditOpen(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-4 px-6 py-6">
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-600">Question Text</label>
<Textarea value={editQuestionText} onChange={(e) => setEditQuestionText(e.target.value)} rows={3} />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
<Select value={editQuestionType} onChange={(e) => setEditQuestionType(e.target.value as QuestionTypeEdit)}>
<option value="MCQ">Multiple Choice</option>
<option value="TRUE_FALSE">True/False</option>
<option value="SHORT_ANSWER">Short Answer</option>
<option value="AUDIO">Audio</option>
</Select>
<Select value={editDifficulty} onChange={(e) => setEditDifficulty(e.target.value)}>
<option value="EASY">Easy</option>
<option value="MEDIUM">Medium</option>
<option value="HARD">Hard</option>
</Select>
<Input type="number" min={1} value={editPoints} onChange={(e) => setEditPoints(Number(e.target.value) || 1)} />
<Select value={editStatus} onChange={(e) => setEditStatus(e.target.value)}>
<option value="DRAFT">Draft</option>
<option value="PUBLISHED">Published</option>
<option value="INACTIVE">Inactive</option>
</Select>
</div>
{editQuestionType !== "SHORT_ANSWER" && editQuestionType !== "AUDIO" && (
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-600">Options</label>
{editOptions.map((opt, idx) => (
<div key={idx} className="flex items-center gap-2">
<input
type="radio"
checked={opt.is_correct}
onChange={() =>
setEditOptions((prev) =>
prev.map((item, i) => ({ ...item, is_correct: i === idx })),
)
}
/>
<Input
value={opt.option_text}
onChange={(e) =>
setEditOptions((prev) =>
prev.map((item, i) =>
i === idx ? { ...item, option_text: e.target.value } : item,
),
)
}
placeholder={`Option ${idx + 1}`}
/>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setEditOptions((prev) => [
...prev,
{ option_text: "", option_order: prev.length + 1, is_correct: false },
])
}
>
<Plus className="h-4 w-4" />
Add Option
</Button>
</div>
)}
{editQuestionType === "SHORT_ANSWER" && (
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-600">Short Answer</label>
<Input value={editShortAnswer} onChange={(e) => setEditShortAnswer(e.target.value)} />
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input value={editTips} onChange={(e) => setEditTips(e.target.value)} placeholder="Tips (optional)" />
<Input value={editExplanation} onChange={(e) => setEditExplanation(e.target.value)} placeholder="Explanation (optional)" />
<Input value={editVoicePrompt} onChange={(e) => setEditVoicePrompt(e.target.value)} placeholder="Voice prompt (optional)" />
<Input value={editSampleAnswerVoicePrompt} onChange={(e) => setEditSampleAnswerVoicePrompt(e.target.value)} placeholder="Sample answer voice prompt (optional)" />
</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={() => setEditOpen(false)} disabled={savingEdit}>
Cancel
</Button>
<Button className="bg-brand-500 hover:bg-brand-600" onClick={saveEdit} disabled={savingEdit}>
{savingEdit ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -1,9 +1,164 @@
import { Link } from "react-router-dom"
import { Plus, Mic } from "lucide-react"
import { Card, CardContent } from "../../components/ui/card"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Plus, Mic, X } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select"
import {
addQuestionToSet,
createQuestion,
createQuestionSet,
getQuestions,
} from "../../api/courses.api"
import type { QuestionDetail } from "../../types/course.types"
export function SpeakingPage() {
const [audioQuestions, setAudioQuestions] = useState<QuestionDetail[]>([])
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [openCreate, setOpenCreate] = useState(false)
const [setTitle, setSetTitle] = useState("")
const [setDescription, setSetDescription] = useState("")
const [ownerType, setOwnerType] = useState<"SUB_COURSE" | "COURSE">("SUB_COURSE")
const [ownerId, setOwnerId] = useState("")
const [setStatus, setSetStatus] = useState<"DRAFT" | "PUBLISHED">("PUBLISHED")
const [questionText, setQuestionText] = useState("")
const [difficulty, setDifficulty] = useState("EASY")
const [points, setPoints] = useState(1)
const [voicePrompt, setVoicePrompt] = useState("")
const [sampleAnswerVoicePrompt, setSampleAnswerVoicePrompt] = useState("")
const [audioCorrectAnswerText, setAudioCorrectAnswerText] = useState("")
const fetchAudioQuestions = useCallback(async () => {
setLoading(true)
try {
const batchSize = 100
let nextOffset = 0
let expectedTotal = Number.POSITIVE_INFINITY
let allRows: QuestionDetail[] = []
while (allRows.length < expectedTotal) {
const res = await getQuestions({
question_type: "AUDIO",
limit: batchSize,
offset: nextOffset,
})
const payload = res.data?.data as unknown
const meta = res.data?.metadata as { total_count?: number } | null | undefined
let chunk: QuestionDetail[] = []
let chunkTotal: number | undefined
if (Array.isArray(payload)) {
chunk = payload as QuestionDetail[]
chunkTotal = meta?.total_count
} else if (
payload &&
typeof payload === "object" &&
Array.isArray((payload as { questions?: unknown[] }).questions)
) {
const data = payload as { questions: QuestionDetail[]; total_count?: number }
chunk = data.questions
chunkTotal = data.total_count ?? meta?.total_count
}
allRows = [...allRows, ...chunk]
if (typeof chunkTotal === "number" && Number.isFinite(chunkTotal)) {
expectedTotal = chunkTotal
}
if (chunk.length < batchSize) break
nextOffset += chunk.length
}
setAudioQuestions(allRows)
} catch (error) {
console.error("Failed to fetch audio questions:", error)
setAudioQuestions([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchAudioQuestions()
}, [fetchAudioQuestions])
const resetCreateForm = () => {
setSetTitle("")
setSetDescription("")
setOwnerType("SUB_COURSE")
setOwnerId("")
setSetStatus("PUBLISHED")
setQuestionText("")
setDifficulty("EASY")
setPoints(1)
setVoicePrompt("")
setSampleAnswerVoicePrompt("")
setAudioCorrectAnswerText("")
}
const canCreate = useMemo(() => {
return (
setTitle.trim().length > 0 &&
ownerId.trim().length > 0 &&
questionText.trim().length > 0 &&
voicePrompt.trim().length > 0 &&
sampleAnswerVoicePrompt.trim().length > 0 &&
audioCorrectAnswerText.trim().length > 0
)
}, [setTitle, ownerId, questionText, voicePrompt, sampleAnswerVoicePrompt, audioCorrectAnswerText])
const handleCreateSpeakingPractice = async () => {
if (!canCreate) return
const parsedOwnerId = Number(ownerId)
if (!Number.isFinite(parsedOwnerId) || parsedOwnerId <= 0) return
setSaving(true)
try {
const setRes = await createQuestionSet({
title: setTitle.trim(),
description: setDescription.trim(),
set_type: "PRACTICE",
owner_type: ownerType,
owner_id: parsedOwnerId,
status: setStatus,
})
const setId = setRes.data?.data?.id
if (!setId) throw new Error("Question set creation failed: missing set ID")
const questionRes = await createQuestion({
question_text: questionText.trim(),
question_type: "AUDIO",
status: "PUBLISHED",
difficulty_level: difficulty,
points,
voice_prompt: voicePrompt.trim(),
sample_answer_voice_prompt: sampleAnswerVoicePrompt.trim(),
audio_correct_answer_text: audioCorrectAnswerText.trim(),
})
const questionId = questionRes.data?.data?.id
if (!questionId) throw new Error("Question creation failed: missing question ID")
await addQuestionToSet(setId, {
question_id: questionId,
display_order: 1,
})
setOpenCreate(false)
resetCreateForm()
await fetchAudioQuestions()
} catch (error) {
console.error("Failed to create speaking practice:", error)
} finally {
setSaving(false)
}
}
return (
<div className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@ -15,35 +170,157 @@ export function SpeakingPage() {
Create and manage speaking practice sessions for your learners.
</p>
</div>
<Link to="/content/speaking/add-practice" className="w-full sm:w-auto">
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
<Plus className="h-4 w-4" />
Add New Practice
</Button>
</Link>
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto" onClick={() => setOpenCreate(true)}>
<Plus className="h-4 w-4" />
Add New Speaking Practice
</Button>
</div>
<Card className="border-2 border-dashed border-grayScale-200 shadow-none">
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-6 grid h-20 w-20 place-items-center rounded-full bg-gradient-to-br from-brand-100 to-brand-200">
<Mic className="h-10 w-10 text-brand-500" />
</div>
<h3 className="text-lg font-semibold text-grayScale-600">
No speaking practices yet
</h3>
<p className="mt-2 max-w-md text-sm leading-relaxed text-grayScale-400">
Get started by adding your first speaking practice session. Your
learners will be able to practice pronunciation and conversation
skills.
</p>
<Link to="/content/speaking/add-practice" className="mt-8">
<Button className="bg-brand-500 px-6 hover:bg-brand-600">
<Plus className="h-4 w-4" />
Create Your First Practice
</Button>
</Link>
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-4">
<CardTitle className="text-base font-semibold text-grayScale-600">
AUDIO Questions
</CardTitle>
</CardHeader>
<CardContent className="pt-5">
{loading ? (
<div className="py-14 text-center text-sm text-grayScale-500">Loading audio questions...</div>
) : audioQuestions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-14 text-center">
<div className="mb-6 grid h-16 w-16 place-items-center rounded-full bg-gradient-to-br from-brand-100 to-brand-200">
<Mic className="h-8 w-8 text-brand-500" />
</div>
<h3 className="text-base font-semibold text-grayScale-600">No audio questions yet</h3>
<p className="mt-2 max-w-md text-sm leading-relaxed text-grayScale-400">
Create a speaking practice to automatically create and attach an AUDIO question.
</p>
</div>
) : (
<div className="space-y-3">
{audioQuestions.map((question, idx) => (
<div
key={question.id}
className={`rounded-lg border px-4 py-3 ${
idx % 2 === 0 ? "border-grayScale-200 bg-white" : "border-grayScale-100 bg-grayScale-50"
}`}
>
<p className="text-sm font-medium text-grayScale-700">{question.question_text}</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-md bg-purple-100 px-2 py-1 text-purple-700">AUDIO</span>
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
Difficulty: {question.difficulty_level || "—"}
</span>
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
Points: {question.points ?? 0}
</span>
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
Status: {question.status || "—"}
</span>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{openCreate && (
<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-2xl 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">Create Speaking Practice</h2>
<button
onClick={() => setOpenCreate(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="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<label className="text-sm font-medium text-grayScale-600">Practice Title</label>
<Input value={setTitle} onChange={(e) => setSetTitle(e.target.value)} placeholder="Speaking practice title" />
</div>
<div className="space-y-1.5 sm:col-span-2">
<label className="text-sm font-medium text-grayScale-600">Practice Description (Optional)</label>
<Textarea value={setDescription} onChange={(e) => setSetDescription(e.target.value)} rows={2} placeholder="Brief description" />
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-600">Owner Type</label>
<Select value={ownerType} onChange={(e) => setOwnerType(e.target.value as "SUB_COURSE" | "COURSE")}>
<option value="SUB_COURSE">SUB_COURSE</option>
<option value="COURSE">COURSE</option>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-600">Owner ID</label>
<Input
type="number"
min={1}
value={ownerId}
onChange={(e) => setOwnerId(e.target.value)}
placeholder="e.g. 12"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-600">Set Status</label>
<Select value={setStatus} onChange={(e) => setSetStatus(e.target.value as "DRAFT" | "PUBLISHED")}>
<option value="PUBLISHED">PUBLISHED</option>
<option value="DRAFT">DRAFT</option>
</Select>
</div>
</div>
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-4">
<p className="mb-3 text-sm font-semibold text-grayScale-700">AUDIO Question</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<label className="text-sm font-medium text-grayScale-600">Question Text</label>
<Textarea value={questionText} onChange={(e) => setQuestionText(e.target.value)} rows={2} />
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-600">Difficulty</label>
<Select value={difficulty} onChange={(e) => setDifficulty(e.target.value)}>
<option value="EASY">EASY</option>
<option value="MEDIUM">MEDIUM</option>
<option value="HARD">HARD</option>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-600">Points</label>
<Input type="number" min={1} value={points} onChange={(e) => setPoints(Number(e.target.value) || 1)} />
</div>
<div className="space-y-1.5 sm:col-span-2">
<label className="text-sm font-medium text-grayScale-600">Voice Prompt</label>
<Textarea value={voicePrompt} onChange={(e) => setVoicePrompt(e.target.value)} rows={2} />
</div>
<div className="space-y-1.5 sm:col-span-2">
<label className="text-sm font-medium text-grayScale-600">Sample Answer Voice Prompt</label>
<Textarea value={sampleAnswerVoicePrompt} onChange={(e) => setSampleAnswerVoicePrompt(e.target.value)} rows={2} />
</div>
<div className="space-y-1.5 sm:col-span-2">
<label className="text-sm font-medium text-grayScale-600">Audio Correct Answer Text</label>
<Textarea value={audioCorrectAnswerText} onChange={(e) => setAudioCorrectAnswerText(e.target.value)} rows={2} />
</div>
</div>
</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={() => setOpenCreate(false)} disabled={saving}>
Cancel
</Button>
<Button
className="bg-brand-500 hover:bg-brand-600"
disabled={!canCreate || saving}
onClick={handleCreateSpeakingPractice}
>
{saving ? "Creating..." : "Create Practice + Audio Question"}
</Button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -26,7 +26,10 @@ import { cn } from "../../lib/utils";
import { useUsersStore } from "../../zustand/userStore";
import { getUserById } from "../../api/users.api";
import { getCourseCategories, getCoursesByCategory } from "../../api/courses.api";
import { getAdminLearnerCourseProgress } from "../../api/progress.api";
import {
getAdminLearnerCourseProgress,
getAdminLearnerCourseProgressSummary,
} from "../../api/progress.api";
import {
Table,
TableBody,
@ -36,7 +39,7 @@ import {
TableRow,
} from "../../components/ui/table";
import { Select } from "../../components/ui/select";
import type { LearnerCourseProgressItem } from "../../types/progress.types";
import type { LearnerCourseProgressItem, LearnerCourseProgressSummary } from "../../types/progress.types";
import type { Course } from "../../types/course.types";
const activityIcons: Record<string, typeof CheckCircle2> = {
@ -55,6 +58,7 @@ export function UserDetailPage() {
const [loadingCourseOptions, setLoadingCourseOptions] = useState(false);
const [selectedProgressCourseId, setSelectedProgressCourseId] = useState<number | null>(null);
const [progressItems, setProgressItems] = useState<LearnerCourseProgressItem[]>([]);
const [progressSummary, setProgressSummary] = useState<LearnerCourseProgressSummary | null>(null);
const [loadingProgress, setLoadingProgress] = useState(false);
const [progressError, setProgressError] = useState<string | null>(null);
@ -115,12 +119,18 @@ export function UserDetailPage() {
setLoadingProgress(true);
setProgressError(null);
try {
const res = await getAdminLearnerCourseProgress(userId, selectedProgressCourseId);
const ordered = [...(res.data?.data ?? [])].sort(
const [summaryRes, detailRes] = await Promise.all([
getAdminLearnerCourseProgressSummary(userId, selectedProgressCourseId),
getAdminLearnerCourseProgress(userId, selectedProgressCourseId),
]);
setProgressSummary(summaryRes.data?.data ?? null);
const ordered = [...(detailRes.data?.data ?? [])].sort(
(a, b) => a.display_order - b.display_order || a.sub_course_id - b.sub_course_id,
);
setProgressItems(ordered);
} catch (err: any) {
setProgressSummary(null);
setProgressItems([]);
const status = err?.response?.status;
if (status === 403) {
@ -139,6 +149,16 @@ export function UserDetailPage() {
}, [id, selectedProgressCourseId]);
const progressMetrics = useMemo(() => {
if (progressSummary) {
return {
total: progressSummary.total_sub_courses ?? 0,
completed: progressSummary.completed_sub_courses ?? 0,
inProgress: progressSummary.in_progress_sub_courses ?? 0,
locked: progressSummary.locked_sub_courses ?? 0,
averageProgress: Math.round(progressSummary.overall_progress_percentage ?? 0),
};
}
const total = progressItems.length;
const completed = progressItems.filter((item) => item.progress_status === "COMPLETED").length;
const inProgress = progressItems.filter((item) => item.progress_status === "IN_PROGRESS").length;
@ -151,7 +171,7 @@ export function UserDetailPage() {
);
return { total, completed, inProgress, locked, averageProgress };
}, [progressItems]);
}, [progressItems, progressSummary]);
if (!userProfile) {
return (
@ -214,7 +234,7 @@ export function UserDetailPage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<Link
to="/users"
to="/users/list"
className="inline-flex items-center gap-2 text-sm font-medium text-grayScale-500 transition-colors hover:text-brand-600"
>
<ArrowLeft className="h-4 w-4" />
@ -440,7 +460,7 @@ export function UserDetailPage() {
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
<Metric label="Total Courses" value={progressMetrics.total} />
<Metric label="Total Sub-courses" value={progressMetrics.total} />
<Metric label="Completed" value={progressMetrics.completed} />
<Metric label="In Progress" value={progressMetrics.inProgress} />
<Metric label="Locked" value={progressMetrics.locked} />

View File

@ -229,6 +229,7 @@ export function UsersListPage() {
/>
</TableHead>
<TableHead>USER</TableHead>
<TableHead className="hidden md:table-cell">Role</TableHead>
<TableHead className="hidden md:table-cell">Phone</TableHead>
<TableHead className="hidden md:table-cell">Country</TableHead>
<TableHead className="hidden md:table-cell">Region</TableHead>
@ -239,7 +240,7 @@ export function UsersListPage() {
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-16 text-center">
<TableCell colSpan={7} className="py-16 text-center">
<div className="flex flex-col items-center gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-grayScale-100">
<Users className="h-7 w-7 text-grayScale-400" />
@ -283,6 +284,7 @@ export function UsersListPage() {
</div>
</div>
</TableCell>
<TableCell className="hidden md:table-cell text-grayScale-500">{u.role || "-"}</TableCell>
<TableCell className="hidden md:table-cell text-grayScale-500">{u.phoneNumber || "-"}</TableCell>
<TableCell className="hidden md:table-cell text-grayScale-500">{u.country || "-"}</TableCell>
<TableCell className="hidden md:table-cell text-grayScale-500">{u.region || "-"}</TableCell>

View File

@ -318,6 +318,8 @@ export interface PracticeQuestion {
id: number
practice_id: number
question: string
points?: number
difficulty_level?: string
question_voice_prompt: string
sample_answer_voice_prompt: string
sample_answer: string
@ -343,6 +345,11 @@ export interface CreatePracticeQuestionRequest {
sample_answer_voice_prompt?: string
sample_answer: string
tips?: string
explanation?: string
difficulty_level?: string
points?: number
options?: QuestionOption[]
short_answers?: string[]
type: "MCQ" | "TRUE_FALSE" | "SHORT"
}
@ -352,6 +359,11 @@ export interface UpdatePracticeQuestionRequest {
sample_answer_voice_prompt?: string
sample_answer: string
tips?: string
explanation?: string
difficulty_level?: string
points?: number
options?: QuestionOption[]
short_answers?: string[]
type: "MCQ" | "TRUE_FALSE" | "SHORT"
}
@ -375,7 +387,65 @@ export interface QuestionSet {
export interface GetQuestionSetsResponse {
message: string
data: QuestionSet[]
data: QuestionSet[] | { question_sets: QuestionSet[]; total_count?: number }
success: boolean
status_code: number
metadata: unknown
}
export interface GetQuestionSetsParams {
set_type?: "PRACTICE" | "INITIAL_ASSESSMENT" | "EXAM" | string
owner_type?: "SUB_COURSE" | "COURSE" | string
owner_id?: number
status?: "DRAFT" | "PUBLISHED" | "ARCHIVED" | string
limit?: number
offset?: number
}
export interface QuestionSetDetail {
id: number
title: string
description: string
set_type: string
owner_type: string
owner_id: number
banner_image?: string | null
persona?: string | null
time_limit_minutes?: number | null
passing_score?: number | null
shuffle_questions?: boolean
status: string
sub_course_video_id?: number | null
created_at: string
question_count: number
}
export interface GetQuestionSetDetailResponse {
message: string
data: QuestionSetDetail
success: boolean
status_code: number
metadata: unknown
}
export interface QuestionSetQuestion {
id: number
set_id: number
question_id: number
display_order: number
question_text: string
question_type: "MCQ" | "TRUE_FALSE" | "SHORT" | string
difficulty_level?: string | null
points?: number
explanation?: string | null
tips?: string | null
voice_prompt?: string | null
question_status?: string
}
export interface GetQuestionSetQuestionsResponse {
message: string
data: QuestionSetQuestion[]
success: boolean
status_code: number
metadata: unknown
@ -396,7 +466,7 @@ export interface CreateQuestionSetRequest {
}
export interface AddQuestionToSetRequest {
display_order: number
display_order?: number
question_id: number
}
@ -409,15 +479,16 @@ export interface QuestionOption {
export interface CreateQuestionRequest {
question_text: string
question_type: string
difficulty_level: string
points: number
difficulty_level?: string
points?: number
tips?: string
explanation?: string
status?: string
options?: QuestionOption[]
voice_prompt?: string
sample_answer_voice_prompt?: string
short_answers?: string[]
audio_correct_answer_text?: string
short_answers?: string[] | { acceptable_answer: string; match_type: "EXACT" | "CASE_INSENSITIVE" }[]
}
export interface CreateQuestionResponse {
@ -430,6 +501,52 @@ export interface CreateQuestionResponse {
metadata: unknown
}
export interface QuestionShortAnswer {
acceptable_answer: string
match_type?: "EXACT" | "CASE_INSENSITIVE" | string
}
export interface QuestionDetail {
id: number
question_text: string
question_type: "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "SHORT" | string
difficulty_level?: string | null
points?: number | null
status?: string
created_at?: string
options?: ({ id?: number } & QuestionOption)[]
short_answers?: string[] | QuestionShortAnswer[]
tips?: string | null
explanation?: string | null
voice_prompt?: string | null
sample_answer_voice_prompt?: string | null
audio_correct_answer_text?: string | null
}
export interface GetQuestionDetailResponse {
message: string
data: QuestionDetail
success: boolean
status_code: number
metadata: unknown
}
export interface GetQuestionsParams {
question_type?: "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" | string
difficulty?: "EASY" | "MEDIUM" | "HARD" | string
status?: "DRAFT" | "PUBLISHED" | "INACTIVE" | string
limit?: number
offset?: number
}
export interface GetQuestionsResponse {
message: string
data: QuestionDetail[] | { questions: QuestionDetail[]; total_count?: number }
success: boolean
status_code: number
metadata: unknown
}
export interface CreateQuestionSetResponse {
message: string
data: {

View File

@ -18,3 +18,19 @@ export interface LearnerCourseProgressResponse {
message: string
data: LearnerCourseProgressItem[]
}
export interface LearnerCourseProgressSummary {
course_id: number
learner_user_id: number
overall_progress_percentage: number
total_sub_courses: number
completed_sub_courses: number
in_progress_sub_courses: number
not_started_sub_courses: number
locked_sub_courses: number
}
export interface LearnerCourseProgressSummaryResponse {
message: string
data: LearnerCourseProgressSummary
}

View File

@ -50,6 +50,7 @@ export interface User {
nickName: string
email: string
phoneNumber: string
role: string
region: string
country: string
lastLogin: string | null
@ -63,6 +64,7 @@ export const mapUserApiToUser = (u: UserApiDTO): User => ({
nickName: u.nick_name,
email: u.email,
phoneNumber: u.phone_number ?? "",
role: u.role,
region: u.region,
country: u.country,
lastLogin: null,