speaking section partly integration + more table filters + practice and question pages fixes for real data
This commit is contained in:
parent
31912d2e58
commit
e2c61385ae
|
|
@ -15,7 +15,6 @@ import type {
|
||||||
CreatePracticeRequest,
|
CreatePracticeRequest,
|
||||||
UpdatePracticeRequest,
|
UpdatePracticeRequest,
|
||||||
UpdatePracticeStatusRequest,
|
UpdatePracticeStatusRequest,
|
||||||
GetPracticeQuestionsResponse,
|
|
||||||
CreatePracticeQuestionRequest,
|
CreatePracticeQuestionRequest,
|
||||||
UpdatePracticeQuestionRequest,
|
UpdatePracticeQuestionRequest,
|
||||||
GetProgramsResponse,
|
GetProgramsResponse,
|
||||||
|
|
@ -31,11 +30,17 @@ import type {
|
||||||
UpdateModuleRequest,
|
UpdateModuleRequest,
|
||||||
UpdateModuleStatusRequest,
|
UpdateModuleStatusRequest,
|
||||||
GetQuestionSetsResponse,
|
GetQuestionSetsResponse,
|
||||||
|
GetQuestionSetsParams,
|
||||||
|
GetQuestionSetDetailResponse,
|
||||||
|
GetQuestionSetQuestionsResponse,
|
||||||
CreateQuestionSetRequest,
|
CreateQuestionSetRequest,
|
||||||
CreateQuestionSetResponse,
|
CreateQuestionSetResponse,
|
||||||
AddQuestionToSetRequest,
|
AddQuestionToSetRequest,
|
||||||
CreateQuestionRequest,
|
CreateQuestionRequest,
|
||||||
CreateQuestionResponse,
|
CreateQuestionResponse,
|
||||||
|
GetQuestionDetailResponse,
|
||||||
|
GetQuestionsParams,
|
||||||
|
GetQuestionsResponse,
|
||||||
CreateVimeoVideoRequest,
|
CreateVimeoVideoRequest,
|
||||||
CreateCourseCategoryRequest,
|
CreateCourseCategoryRequest,
|
||||||
GetSubCoursePrerequisitesResponse,
|
GetSubCoursePrerequisitesResponse,
|
||||||
|
|
@ -119,7 +124,7 @@ export const deletePractice = (practiceId: number) =>
|
||||||
|
|
||||||
// Practice Questions APIs
|
// Practice Questions APIs
|
||||||
export const getPracticeQuestions = (practiceId: number) =>
|
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) =>
|
export const createPracticeQuestion = (data: CreatePracticeQuestionRequest) =>
|
||||||
http.post("/course-management/practice-questions", data)
|
http.post("/course-management/practice-questions", data)
|
||||||
|
|
@ -187,11 +192,20 @@ export const getPracticesByModule = (moduleId: number) =>
|
||||||
http.get<GetPracticesResponse>(`/course-management/modules/${moduleId}/practices`)
|
http.get<GetPracticesResponse>(`/course-management/modules/${moduleId}/practices`)
|
||||||
|
|
||||||
// Question Sets API
|
// Question Sets API
|
||||||
|
export const getQuestionSets = (params?: GetQuestionSetsParams) =>
|
||||||
|
http.get<GetQuestionSetsResponse>("/question-sets", { params })
|
||||||
|
|
||||||
export const getQuestionSetsByOwner = (ownerType: string, ownerId: number) =>
|
export const getQuestionSetsByOwner = (ownerType: string, ownerId: number) =>
|
||||||
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
|
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
|
||||||
params: { owner_type: ownerType, owner_id: ownerId },
|
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) =>
|
export const createQuestionSet = (data: CreateQuestionSetRequest) =>
|
||||||
http.post<CreateQuestionSetResponse>("/question-sets", data)
|
http.post<CreateQuestionSetResponse>("/question-sets", data)
|
||||||
|
|
||||||
|
|
@ -201,6 +215,18 @@ export const addQuestionToSet = (questionSetId: number, data: AddQuestionToSetRe
|
||||||
export const createQuestion = (data: CreateQuestionRequest) =>
|
export const createQuestion = (data: CreateQuestionRequest) =>
|
||||||
http.post<CreateQuestionResponse>("/questions", data)
|
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) =>
|
export const deleteQuestionSet = (questionSetId: number) =>
|
||||||
http.delete(`/question-sets/${questionSetId}`)
|
http.delete(`/question-sets/${questionSetId}`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import http from "./http"
|
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) =>
|
export const getAdminLearnerCourseProgress = (userId: number, courseId: number) =>
|
||||||
http.get<LearnerCourseProgressResponse>(`/admin/users/${userId}/progress/courses/${courseId}`)
|
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`,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useNavigate, useParams } from "react-router-dom"
|
import { useNavigate, useParams } from "react-router-dom"
|
||||||
import { ArrowLeft, Plus, X } from "lucide-react"
|
import { ArrowLeft, Plus, X } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
@ -7,30 +7,41 @@ import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/ca
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
import { Select } from "../../components/ui/select"
|
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 {
|
interface Question {
|
||||||
id: string
|
id?: number
|
||||||
question: string
|
question: string
|
||||||
type: QuestionType
|
type: QuestionType
|
||||||
options: string[]
|
options: string[]
|
||||||
correctAnswer: string
|
correctAnswer: string
|
||||||
points: number
|
points: number
|
||||||
category?: string
|
difficulty: Difficulty
|
||||||
difficulty?: string
|
status: QuestionStatus
|
||||||
|
tips: string
|
||||||
|
explanation: string
|
||||||
|
voicePrompt: string
|
||||||
|
sampleAnswerVoicePrompt: string
|
||||||
|
audioCorrectAnswerText: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock data for editing
|
const initialForm: Question = {
|
||||||
const mockQuestion: Question = {
|
|
||||||
id: "1",
|
|
||||||
question: "",
|
question: "",
|
||||||
type: "multiple-choice",
|
type: "MCQ",
|
||||||
options: ["", "", "", ""],
|
options: ["", "", "", ""],
|
||||||
correctAnswer: "",
|
correctAnswer: "",
|
||||||
points: 10,
|
points: 1,
|
||||||
category: "",
|
difficulty: "EASY",
|
||||||
difficulty: "",
|
status: "PUBLISHED",
|
||||||
|
tips: "",
|
||||||
|
explanation: "",
|
||||||
|
voicePrompt: "",
|
||||||
|
sampleAnswerVoicePrompt: "",
|
||||||
|
audioCorrectAnswerText: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddQuestionPage() {
|
export function AddQuestionPage() {
|
||||||
|
|
@ -38,36 +49,83 @@ export function AddQuestionPage() {
|
||||||
const { id } = useParams<{ id?: string }>()
|
const { id } = useParams<{ id?: string }>()
|
||||||
const isEditing = !!id
|
const isEditing = !!id
|
||||||
|
|
||||||
const [formData, setFormData] = useState<Question>(
|
const [formData, setFormData] = useState<Question>(initialForm)
|
||||||
isEditing
|
const [loading, setLoading] = useState(false)
|
||||||
? mockQuestion // In a real app, fetch the question by id
|
const [submitting, setSubmitting] = useState(false)
|
||||||
: {
|
|
||||||
id: Date.now().toString(),
|
useEffect(() => {
|
||||||
question: "",
|
const loadQuestion = async () => {
|
||||||
type: "multiple-choice",
|
if (!isEditing || !id) return
|
||||||
options: ["", "", "", ""],
|
setLoading(true)
|
||||||
correctAnswer: "",
|
try {
|
||||||
points: 10,
|
const res = await getQuestionById(Number(id))
|
||||||
category: "",
|
const q = res.data.data
|
||||||
difficulty: "",
|
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) => {
|
const handleTypeChange = (type: QuestionType) => {
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
if (type === "true-false") {
|
if (type === "TRUE_FALSE") {
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
type,
|
type,
|
||||||
options: ["True", "False"],
|
options: ["True", "False"],
|
||||||
correctAnswer: prev.correctAnswer === "True" || prev.correctAnswer === "False" ? prev.correctAnswer : "",
|
correctAnswer: prev.correctAnswer === "True" || prev.correctAnswer === "False" ? prev.correctAnswer : "",
|
||||||
}
|
}
|
||||||
} else if (type === "short-answer") {
|
} else if (type === "SHORT_ANSWER" || type === "AUDIO") {
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
type,
|
type,
|
||||||
options: [],
|
options: [],
|
||||||
correctAnswer: "",
|
correctAnswer: type === "AUDIO" ? prev.audioCorrectAnswerText : prev.correctAnswer,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
|
|
@ -101,7 +159,7 @@ export function AddQuestionPage() {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
|
|
@ -112,14 +170,14 @@ export function AddQuestionPage() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.type === "multiple-choice" || formData.type === "true-false") {
|
if (formData.type === "MCQ" || formData.type === "TRUE_FALSE") {
|
||||||
if (!formData.correctAnswer) {
|
if (!formData.correctAnswer) {
|
||||||
toast.error("Missing correct answer", {
|
toast.error("Missing correct answer", {
|
||||||
description: "Select the correct answer for this question.",
|
description: "Select the correct answer for this question.",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (formData.type === "multiple-choice") {
|
if (formData.type === "MCQ") {
|
||||||
const hasEmptyOptions = formData.options.some((opt) => !opt.trim())
|
const hasEmptyOptions = formData.options.some((opt) => !opt.trim())
|
||||||
if (hasEmptyOptions) {
|
if (hasEmptyOptions) {
|
||||||
toast.error("Incomplete options", {
|
toast.error("Incomplete options", {
|
||||||
|
|
@ -128,23 +186,74 @@ export function AddQuestionPage() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (formData.type === "short-answer") {
|
} else if (formData.type === "SHORT_ANSWER") {
|
||||||
if (!formData.correctAnswer.trim()) {
|
if (!formData.correctAnswer.trim()) {
|
||||||
toast.error("Missing correct answer", {
|
toast.error("Missing correct answer", {
|
||||||
description: "Enter the expected correct answer.",
|
description: "Enter the expected correct answer.",
|
||||||
})
|
})
|
||||||
return
|
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
|
setSubmitting(true)
|
||||||
console.log("Saving question:", formData)
|
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", {
|
toast.success(isEditing ? "Question updated" : "Question created", {
|
||||||
description: isEditing
|
description: isEditing
|
||||||
? "The question has been updated successfully."
|
? "The question has been updated successfully."
|
||||||
: "Your new question has been created.",
|
: "Your new question has been created.",
|
||||||
})
|
})
|
||||||
navigate("/content/questions")
|
navigate("/content/questions")
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save question:", error)
|
||||||
|
toast.error("Failed to save question")
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -170,6 +279,11 @@ export function AddQuestionPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-3xl mx-auto">
|
<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}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Card className="shadow-sm border border-grayScale-100 rounded-xl">
|
<Card className="shadow-sm border border-grayScale-100 rounded-xl">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
|
|
@ -185,9 +299,10 @@ export function AddQuestionPage() {
|
||||||
value={formData.type}
|
value={formData.type}
|
||||||
onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
|
onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
|
||||||
>
|
>
|
||||||
<option value="multiple-choice">Multiple Choice</option>
|
<option value="MCQ">Multiple Choice</option>
|
||||||
<option value="short-answer">Short Answer</option>
|
<option value="TRUE_FALSE">True/False</option>
|
||||||
<option value="true-false">True/False</option>
|
<option value="SHORT_ANSWER">Short Answer</option>
|
||||||
|
<option value="AUDIO">Audio</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -209,7 +324,7 @@ export function AddQuestionPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Options for Multiple Choice */}
|
{/* Options for Multiple Choice */}
|
||||||
{(formData.type === "multiple-choice" || formData.type === "true-false") && (
|
{(formData.type === "MCQ" || formData.type === "TRUE_FALSE") && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Options
|
Options
|
||||||
|
|
@ -224,10 +339,10 @@ export function AddQuestionPage() {
|
||||||
value={option}
|
value={option}
|
||||||
onChange={(e) => handleOptionChange(index, e.target.value)}
|
onChange={(e) => handleOptionChange(index, e.target.value)}
|
||||||
placeholder={`Option ${index + 1}`}
|
placeholder={`Option ${index + 1}`}
|
||||||
disabled={formData.type === "true-false"}
|
disabled={formData.type === "TRUE_FALSE"}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{formData.type === "multiple-choice" && formData.options.length > 2 && (
|
{formData.type === "MCQ" && formData.options.length > 2 && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -240,7 +355,7 @@ export function AddQuestionPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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" />
|
<Plus className="h-4 w-4" />
|
||||||
Add Option
|
Add Option
|
||||||
|
|
@ -255,9 +370,9 @@ export function AddQuestionPage() {
|
||||||
{/* Correct Answer */}
|
{/* Correct Answer */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<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>
|
</label>
|
||||||
{formData.type === "multiple-choice" || formData.type === "true-false" ? (
|
{formData.type === "MCQ" || formData.type === "TRUE_FALSE" ? (
|
||||||
<Select
|
<Select
|
||||||
value={formData.correctAnswer}
|
value={formData.correctAnswer}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
|
@ -274,10 +389,14 @@ export function AddQuestionPage() {
|
||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Enter the correct answer..."
|
placeholder={formData.type === "AUDIO" ? "Enter audio correct answer text..." : "Enter the correct answer..."}
|
||||||
value={formData.correctAnswer}
|
value={formData.type === "AUDIO" ? formData.audioCorrectAnswerText : formData.correctAnswer}
|
||||||
onChange={(e) =>
|
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}
|
rows={2}
|
||||||
required
|
required
|
||||||
|
|
@ -300,7 +419,7 @@ export function AddQuestionPage() {
|
||||||
min="1"
|
min="1"
|
||||||
value={formData.points}
|
value={formData.points}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 0 }))
|
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 1 }))
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
@ -312,27 +431,74 @@ export function AddQuestionPage() {
|
||||||
Difficulty (Optional)
|
Difficulty (Optional)
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.difficulty || ""}
|
value={formData.difficulty}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value as Difficulty }))}
|
||||||
>
|
>
|
||||||
<option value="">Select difficulty</option>
|
<option value="EASY">Easy</option>
|
||||||
<option value="Easy">Easy</option>
|
<option value="MEDIUM">Medium</option>
|
||||||
<option value="Medium">Medium</option>
|
<option value="HARD">Hard</option>
|
||||||
<option value="Hard">Hard</option>
|
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category */}
|
{/* Status */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="category" className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Category (Optional)
|
Status
|
||||||
</label>
|
</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
|
<Input
|
||||||
id="category"
|
value={formData.tips}
|
||||||
placeholder="e.g., Programming, Geography"
|
onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))}
|
||||||
value={formData.category || ""}
|
placeholder="Helpful tip for learners"
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, category: e.target.value }))}
|
/>
|
||||||
|
</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>
|
</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">
|
<Button type="button" variant="outline" onClick={() => navigate("/content/questions")} className="w-full sm:w-auto hover:bg-grayScale-50">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</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"}
|
{isEditing ? "Update Question" : "Create Question"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,299 +1,267 @@
|
||||||
import { useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { Plus, Edit, Trash2 } from "lucide-react"
|
import { RefreshCw } from "lucide-react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Card } from "../../components/ui/card"
|
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
|
||||||
import { Select } from "../../components/ui/select"
|
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 = [
|
const statusColor: Record<string, string> = {
|
||||||
{ id: "1", name: "John Doe", role: "CEO" },
|
PUBLISHED: "bg-green-100 text-green-700",
|
||||||
{ id: "2", name: "Jane Smith", role: "COO" },
|
DRAFT: "bg-amber-100 text-amber-700",
|
||||||
]
|
ARCHIVED: "bg-grayScale-200 text-grayScale-600",
|
||||||
|
}
|
||||||
const mockMembers = [
|
|
||||||
{ id: "1", name: "John Doe", role: "Member" },
|
|
||||||
{ id: "2", name: "Jane Smith", role: "Member" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function PracticeDetailsPage() {
|
export function PracticeDetailsPage() {
|
||||||
const [isMemberModalOpen, setIsMemberModalOpen] = useState(false)
|
const [practices, setPractices] = useState<QuestionSet[]>([])
|
||||||
const [isLeaderModalOpen, setIsLeaderModalOpen] = useState(false)
|
const [selectedPracticeId, setSelectedPracticeId] = useState<number | null>(null)
|
||||||
const [memberName, setMemberName] = useState("")
|
const [selectedPracticeDetail, setSelectedPracticeDetail] = useState<QuestionSetDetail | null>(null)
|
||||||
const [memberRole, setMemberRole] = useState("")
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
const [leaderName, setLeaderName] = useState("")
|
const [loadingList, setLoadingList] = useState(false)
|
||||||
const [leaderRole, setLeaderRole] = useState("")
|
const [loadingDetail, setLoadingDetail] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const [statusFilter, setStatusFilter] = useState("all")
|
||||||
|
const [ownerTypeFilter, setOwnerTypeFilter] = useState("all")
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const fetchPractices = useCallback(async () => {
|
||||||
name: "",
|
setLoadingList(true)
|
||||||
description: "",
|
try {
|
||||||
type: "",
|
const res = await getQuestionSets({ set_type: "PRACTICE" })
|
||||||
street: "",
|
const payload = res.data?.data as unknown
|
||||||
city: "",
|
let sets: QuestionSet[] = []
|
||||||
state: "",
|
if (Array.isArray(payload)) {
|
||||||
zipCode: "",
|
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 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)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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 handleAddMember = () => {
|
const totalCount = useMemo(() => filteredPractices.length, [filteredPractices])
|
||||||
console.log("Add member:", { memberName, memberRole })
|
|
||||||
setIsMemberModalOpen(false)
|
|
||||||
setMemberName("")
|
|
||||||
setMemberRole("")
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddLeader = () => {
|
|
||||||
console.log("Add leader:", { leaderName, leaderRole })
|
|
||||||
setIsLeaderModalOpen(false)
|
|
||||||
setLeaderName("")
|
|
||||||
setLeaderRole("")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
{/* Page Header */}
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Practice Management</h1>
|
<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>
|
<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>
|
||||||
|
|
||||||
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
|
<Card className="shadow-soft">
|
||||||
{/* Practice Leadership */}
|
<CardHeader className="border-b border-grayScale-200 pb-4">
|
||||||
<Card className="border-grayScale-200 p-6 shadow-sm">
|
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||||
<div className="mb-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
Practices ({totalCount})
|
||||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-600">Practice Leadership</h2>
|
</CardTitle>
|
||||||
<Button
|
</CardHeader>
|
||||||
size="sm"
|
<CardContent className="pt-5">
|
||||||
onClick={() => setIsLeaderModalOpen(true)}
|
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors w-full sm:w-auto"
|
<div className="flex-1">
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<Input
|
<Input
|
||||||
value={formData.name}
|
value={searchQuery}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Enter practice name"
|
placeholder="Search by title, description, practice ID, or owner ID..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div>
|
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<option value="all">All Statuses</option>
|
||||||
Practice Description
|
<option value="PUBLISHED">PUBLISHED</option>
|
||||||
</label>
|
<option value="DRAFT">DRAFT</option>
|
||||||
<Textarea
|
<option value="ARCHIVED">ARCHIVED</option>
|
||||||
value={formData.description}
|
</Select>
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
<Select value={ownerTypeFilter} onChange={(e) => setOwnerTypeFilter(e.target.value)}>
|
||||||
placeholder="Enter practice description"
|
<option value="all">All Owner Types</option>
|
||||||
rows={3}
|
<option value="SUB_COURSE">SUB_COURSE</option>
|
||||||
/>
|
<option value="COURSE">COURSE</option>
|
||||||
</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>
|
|
||||||
</Select>
|
</Select>
|
||||||
</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
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => setIsMemberModalOpen(true)}
|
onClick={() => {
|
||||||
className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors w-full sm:w-auto"
|
setSearchQuery("")
|
||||||
|
setStatusFilter("all")
|
||||||
|
setOwnerTypeFilter("all")
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
Clear
|
||||||
Add New Member
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
{mockMembers.map((member) => (
|
|
||||||
<div
|
{loadingList ? (
|
||||||
key={member.id}
|
<div className="py-16 text-center text-sm text-grayScale-500">Loading practices...</div>
|
||||||
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"
|
) : 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 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"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<TableCell className="max-w-md py-3.5">
|
||||||
<div className="grid h-9 w-9 place-items-center rounded-full bg-brand-100 text-sm font-semibold text-brand-600">
|
<p className="truncate text-sm font-medium text-grayScale-700">{practice.title}</p>
|
||||||
{member.name[0]}
|
<p className="mt-1 truncate text-xs text-grayScale-500">{practice.description || "—"}</p>
|
||||||
</div>
|
</TableCell>
|
||||||
<div>
|
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
|
||||||
<p className="font-medium text-grayScale-600">{member.name}</p>
|
{practice.owner_type} #{practice.owner_id}
|
||||||
<p className="text-xs text-grayScale-400">{member.role}</p>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="py-3.5">
|
||||||
</div>
|
<Badge className={statusColor[practice.status] || "bg-grayScale-200 text-grayScale-600"}>
|
||||||
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
{practice.status}
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-grayScale-600">
|
</Badge>
|
||||||
<Edit className="h-4 w-4" />
|
</TableCell>
|
||||||
</Button>
|
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-destructive">
|
{practice.created_at}
|
||||||
<Trash2 className="h-4 w-4" />
|
</TableCell>
|
||||||
</Button>
|
</TableRow>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Add Member Modal */}
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
<Dialog open={isMemberModalOpen} onOpenChange={setIsMemberModalOpen}>
|
<DialogContent className="sm:max-w-2xl">
|
||||||
<DialogContent className="sm:rounded-xl">
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add New Member</DialogTitle>
|
<DialogTitle>Practice Detail</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-5 py-2">
|
{!selectedPracticeId ? (
|
||||||
<div>
|
<p className="text-sm text-grayScale-500">Select a practice from the list to view details.</p>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
) : loadingDetail ? (
|
||||||
Member Name
|
<p className="text-sm text-grayScale-500">Loading detail...</p>
|
||||||
</label>
|
) : !selectedPracticeDetail ? (
|
||||||
<Input
|
<p className="text-sm text-grayScale-500">Failed to load practice detail.</p>
|
||||||
value={memberName}
|
) : (
|
||||||
onChange={(e) => setMemberName(e.target.value)}
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
placeholder="Enter member name"
|
<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>
|
||||||
<div>
|
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Set Type</p>
|
||||||
Member Role
|
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.set_type}</p>
|
||||||
</label>
|
</div>
|
||||||
<Input
|
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3 sm:col-span-2">
|
||||||
value={memberRole}
|
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Description</p>
|
||||||
onChange={(e) => setMemberRole(e.target.value)}
|
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.description || "—"}</p>
|
||||||
placeholder="Enter member role"
|
</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>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,10 +1,11 @@
|
||||||
import { useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
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 { Button } from "../../components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { Select } from "../../components/ui/select"
|
import { Select } from "../../components/ui/select"
|
||||||
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -14,117 +15,294 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table"
|
} from "../../components/ui/table"
|
||||||
import { Badge } from "../../components/ui/badge"
|
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 {
|
interface EditOption {
|
||||||
id: string
|
option_text: string
|
||||||
question: string
|
option_order: number
|
||||||
type: QuestionType
|
is_correct: boolean
|
||||||
options: string[]
|
|
||||||
correctAnswer: string
|
|
||||||
points: number
|
|
||||||
category?: string
|
|
||||||
difficulty?: string
|
|
||||||
createdAt?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock data
|
const typeLabels: Record<string, string> = {
|
||||||
const mockQuestions: Question[] = [
|
MCQ: "Multiple Choice",
|
||||||
{
|
TRUE_FALSE: "True/False",
|
||||||
id: "1",
|
SHORT_ANSWER: "Short Answer",
|
||||||
question: "What is the capital of France?",
|
SHORT: "Short Answer",
|
||||||
type: "multiple-choice",
|
AUDIO: "Audio",
|
||||||
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 typeColors: Record<QuestionType, string> = {
|
const typeColors: Record<string, string> = {
|
||||||
"multiple-choice": "bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-200",
|
MCQ: "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",
|
||||||
"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() {
|
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 [searchQuery, setSearchQuery] = useState("")
|
||||||
const [typeFilter, setTypeFilter] = useState<string>("all")
|
const [typeFilter, setTypeFilter] = useState<QuestionTypeFilter>("all")
|
||||||
const [categoryFilter, setCategoryFilter] = useState<string>("all")
|
const [difficultyFilter, setDifficultyFilter] = useState<DifficultyFilter>("all")
|
||||||
const [difficultyFilter, setDifficultyFilter] = useState<string>("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 fetchQuestions = useCallback(async () => {
|
||||||
const matchesSearch = q.question.toLowerCase().includes(searchQuery.toLowerCase())
|
setLoading(true)
|
||||||
const matchesType = typeFilter === "all" || q.type === typeFilter
|
try {
|
||||||
const matchesCategory = categoryFilter === "all" || q.category === categoryFilter
|
const batchSize = 100
|
||||||
const matchesDifficulty = difficultyFilter === "all" || q.difficulty === difficultyFilter
|
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 payload = res.data?.data as unknown
|
||||||
const difficulties = Array.from(new Set(questions.map((q) => q.difficulty).filter(Boolean)))
|
const meta = res.data?.metadata as { total_count?: number } | null | undefined
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
let chunk: QuestionDetail[] = []
|
||||||
if (window.confirm("Are you sure you want to delete this question?")) {
|
let chunkTotal: number | undefined
|
||||||
setQuestions(questions.filter((q) => q.id !== id))
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
|
|
@ -137,6 +315,16 @@ export function QuestionsPage() {
|
||||||
Create and manage your question bank
|
Create and manage your question bank
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<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 to="/content/questions/add" className="w-full sm:w-auto">
|
<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">
|
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
|
|
@ -144,6 +332,7 @@ export function QuestionsPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="shadow-soft">
|
<Card className="shadow-soft">
|
||||||
<CardHeader className="border-b border-grayScale-200 pb-4">
|
<CardHeader className="border-b border-grayScale-200 pb-4">
|
||||||
|
|
@ -165,51 +354,78 @@ export function QuestionsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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="all">All Types</option>
|
||||||
<option value="multiple-choice">Multiple Choice</option>
|
<option value="MCQ">Multiple Choice</option>
|
||||||
<option value="short-answer">Short Answer</option>
|
<option value="TRUE_FALSE">True/False</option>
|
||||||
<option value="true-false">True/False</option>
|
<option value="SHORT_ANSWER">Short Answer</option>
|
||||||
|
<option value="AUDIO">Audio</option>
|
||||||
</Select>
|
</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
|
<Select
|
||||||
value={difficultyFilter}
|
value={difficultyFilter}
|
||||||
onChange={(e) => setDifficultyFilter(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setDifficultyFilter(e.target.value as DifficultyFilter)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="all">All Difficulties</option>
|
<option value="all">All Difficulties</option>
|
||||||
{difficulties.map((diff) => (
|
<option value="EASY">Easy</option>
|
||||||
<option key={diff} value={diff}>
|
<option value="MEDIUM">Medium</option>
|
||||||
{diff}
|
<option value="HARD">Hard</option>
|
||||||
</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>
|
</Select>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
<div className="text-xs font-medium text-grayScale-400">
|
<div className="text-xs font-medium text-grayScale-400">
|
||||||
Showing {filteredQuestions.length} of {questions.length} questions
|
Showing {paginatedQuestions.length} of {totalCount} questions
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Questions Table */}
|
{/* 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">
|
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
|
<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">
|
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
Question
|
Question
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
|
@ -217,10 +433,10 @@ export function QuestionsPage() {
|
||||||
Type
|
Type
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
||||||
Category
|
Difficulty
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
||||||
Difficulty
|
Status
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
Points
|
Points
|
||||||
|
|
@ -231,65 +447,80 @@ export function QuestionsPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredQuestions.map((question, index) => (
|
{paginatedQuestions.map((question, index) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={question.id}
|
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"
|
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">
|
<TableCell className="max-w-md py-3.5">
|
||||||
<div className="truncate text-sm font-medium text-grayScale-600">
|
<div className="truncate text-sm font-medium text-grayScale-600">
|
||||||
{question.question}
|
{question.question_text}
|
||||||
</div>
|
</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">
|
<div className="mt-1 truncate text-xs text-grayScale-400">
|
||||||
Options: {question.options.join(", ")}
|
Options: {question.options?.map((opt) => opt.option_text).join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-3.5">
|
<TableCell className="py-3.5">
|
||||||
<Badge className={`text-xs font-medium ${typeColors[question.type]}`}>
|
<Badge className={`text-xs font-medium ${typeColors[question.question_type] || "bg-grayScale-100 text-grayScale-600"}`}>
|
||||||
{typeLabels[question.type]}
|
{typeLabels[question.question_type] || question.question_type}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</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">
|
<TableCell className="hidden py-3.5 md:table-cell">
|
||||||
{question.difficulty && (
|
{question.difficulty_level && (
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
question.difficulty === "Easy"
|
question.difficulty_level === "EASY"
|
||||||
? "default"
|
? "default"
|
||||||
: question.difficulty === "Medium"
|
: question.difficulty_level === "MEDIUM"
|
||||||
? "secondary"
|
? "secondary"
|
||||||
: "destructive"
|
: "destructive"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{question.difficulty}
|
{question.difficulty_level}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</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">
|
<TableCell className="py-3.5 text-sm font-semibold text-grayScale-600">
|
||||||
{question.points}
|
{question.points ?? 0}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-3.5 text-right">
|
<TableCell className="py-3.5 text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<Link to={`/content/questions/edit/${question.id}`}>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-grayScale-400 hover:bg-brand-100/50 hover:text-brand-500"
|
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" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-grayScale-400 hover:bg-red-50 hover:text-destructive"
|
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" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -314,8 +545,225 @@ export function QuestionsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,164 @@
|
||||||
import { Link } from "react-router-dom"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { Plus, Mic } from "lucide-react"
|
import { Plus, Mic, X } from "lucide-react"
|
||||||
import { Card, CardContent } from "../../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import { Button } from "../../components/ui/button"
|
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() {
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<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.
|
Create and manage speaking practice sessions for your learners.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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" onClick={() => setOpenCreate(true)}>
|
||||||
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
|
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add New Practice
|
Add New Speaking Practice
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-2 border-dashed border-grayScale-200 shadow-none">
|
<Card className="shadow-soft">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
<CardHeader className="border-b border-grayScale-200 pb-4">
|
||||||
<div className="mb-6 grid h-20 w-20 place-items-center rounded-full bg-gradient-to-br from-brand-100 to-brand-200">
|
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||||
<Mic className="h-10 w-10 text-brand-500" />
|
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>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-grayScale-600">
|
<h3 className="text-base font-semibold text-grayScale-600">No audio questions yet</h3>
|
||||||
No speaking practices yet
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 max-w-md text-sm leading-relaxed text-grayScale-400">
|
<p className="mt-2 max-w-md text-sm leading-relaxed text-grayScale-400">
|
||||||
Get started by adding your first speaking practice session. Your
|
Create a speaking practice to automatically create and attach an AUDIO question.
|
||||||
learners will be able to practice pronunciation and conversation
|
|
||||||
skills.
|
|
||||||
</p>
|
</p>
|
||||||
<Link to="/content/speaking/add-practice" className="mt-8">
|
</div>
|
||||||
<Button className="bg-brand-500 px-6 hover:bg-brand-600">
|
) : (
|
||||||
<Plus className="h-4 w-4" />
|
<div className="space-y-3">
|
||||||
Create Your First Practice
|
{audioQuestions.map((question, idx) => (
|
||||||
</Button>
|
<div
|
||||||
</Link>
|
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>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,10 @@ import { cn } from "../../lib/utils";
|
||||||
import { useUsersStore } from "../../zustand/userStore";
|
import { useUsersStore } from "../../zustand/userStore";
|
||||||
import { getUserById } from "../../api/users.api";
|
import { getUserById } from "../../api/users.api";
|
||||||
import { getCourseCategories, getCoursesByCategory } from "../../api/courses.api";
|
import { getCourseCategories, getCoursesByCategory } from "../../api/courses.api";
|
||||||
import { getAdminLearnerCourseProgress } from "../../api/progress.api";
|
import {
|
||||||
|
getAdminLearnerCourseProgress,
|
||||||
|
getAdminLearnerCourseProgressSummary,
|
||||||
|
} from "../../api/progress.api";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -36,7 +39,7 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
import { Select } from "../../components/ui/select";
|
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";
|
import type { Course } from "../../types/course.types";
|
||||||
|
|
||||||
const activityIcons: Record<string, typeof CheckCircle2> = {
|
const activityIcons: Record<string, typeof CheckCircle2> = {
|
||||||
|
|
@ -55,6 +58,7 @@ export function UserDetailPage() {
|
||||||
const [loadingCourseOptions, setLoadingCourseOptions] = useState(false);
|
const [loadingCourseOptions, setLoadingCourseOptions] = useState(false);
|
||||||
const [selectedProgressCourseId, setSelectedProgressCourseId] = useState<number | null>(null);
|
const [selectedProgressCourseId, setSelectedProgressCourseId] = useState<number | null>(null);
|
||||||
const [progressItems, setProgressItems] = useState<LearnerCourseProgressItem[]>([]);
|
const [progressItems, setProgressItems] = useState<LearnerCourseProgressItem[]>([]);
|
||||||
|
const [progressSummary, setProgressSummary] = useState<LearnerCourseProgressSummary | null>(null);
|
||||||
const [loadingProgress, setLoadingProgress] = useState(false);
|
const [loadingProgress, setLoadingProgress] = useState(false);
|
||||||
const [progressError, setProgressError] = useState<string | null>(null);
|
const [progressError, setProgressError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -115,12 +119,18 @@ export function UserDetailPage() {
|
||||||
setLoadingProgress(true);
|
setLoadingProgress(true);
|
||||||
setProgressError(null);
|
setProgressError(null);
|
||||||
try {
|
try {
|
||||||
const res = await getAdminLearnerCourseProgress(userId, selectedProgressCourseId);
|
const [summaryRes, detailRes] = await Promise.all([
|
||||||
const ordered = [...(res.data?.data ?? [])].sort(
|
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,
|
(a, b) => a.display_order - b.display_order || a.sub_course_id - b.sub_course_id,
|
||||||
);
|
);
|
||||||
setProgressItems(ordered);
|
setProgressItems(ordered);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
setProgressSummary(null);
|
||||||
setProgressItems([]);
|
setProgressItems([]);
|
||||||
const status = err?.response?.status;
|
const status = err?.response?.status;
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
|
|
@ -139,6 +149,16 @@ export function UserDetailPage() {
|
||||||
}, [id, selectedProgressCourseId]);
|
}, [id, selectedProgressCourseId]);
|
||||||
|
|
||||||
const progressMetrics = useMemo(() => {
|
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 total = progressItems.length;
|
||||||
const completed = progressItems.filter((item) => item.progress_status === "COMPLETED").length;
|
const completed = progressItems.filter((item) => item.progress_status === "COMPLETED").length;
|
||||||
const inProgress = progressItems.filter((item) => item.progress_status === "IN_PROGRESS").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 };
|
return { total, completed, inProgress, locked, averageProgress };
|
||||||
}, [progressItems]);
|
}, [progressItems, progressSummary]);
|
||||||
|
|
||||||
if (!userProfile) {
|
if (!userProfile) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -214,7 +234,7 @@ export function UserDetailPage() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Link
|
<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"
|
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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
|
@ -440,7 +460,7 @@ export function UserDetailPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
|
<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="Completed" value={progressMetrics.completed} />
|
||||||
<Metric label="In Progress" value={progressMetrics.inProgress} />
|
<Metric label="In Progress" value={progressMetrics.inProgress} />
|
||||||
<Metric label="Locked" value={progressMetrics.locked} />
|
<Metric label="Locked" value={progressMetrics.locked} />
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,7 @@ export function UsersListPage() {
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>USER</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">Phone</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Country</TableHead>
|
<TableHead className="hidden md:table-cell">Country</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Region</TableHead>
|
<TableHead className="hidden md:table-cell">Region</TableHead>
|
||||||
|
|
@ -239,7 +240,7 @@ export function UsersListPage() {
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<TableRow>
|
<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 flex-col items-center gap-3">
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-grayScale-100">
|
<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" />
|
<Users className="h-7 w-7 text-grayScale-400" />
|
||||||
|
|
@ -283,6 +284,7 @@ export function UsersListPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</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.phoneNumber || "-"}</TableCell>
|
||||||
<TableCell className="hidden md:table-cell text-grayScale-500">{u.country || "-"}</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>
|
<TableCell className="hidden md:table-cell text-grayScale-500">{u.region || "-"}</TableCell>
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,8 @@ export interface PracticeQuestion {
|
||||||
id: number
|
id: number
|
||||||
practice_id: number
|
practice_id: number
|
||||||
question: string
|
question: string
|
||||||
|
points?: number
|
||||||
|
difficulty_level?: string
|
||||||
question_voice_prompt: string
|
question_voice_prompt: string
|
||||||
sample_answer_voice_prompt: string
|
sample_answer_voice_prompt: string
|
||||||
sample_answer: string
|
sample_answer: string
|
||||||
|
|
@ -343,6 +345,11 @@ export interface CreatePracticeQuestionRequest {
|
||||||
sample_answer_voice_prompt?: string
|
sample_answer_voice_prompt?: string
|
||||||
sample_answer: string
|
sample_answer: string
|
||||||
tips?: string
|
tips?: string
|
||||||
|
explanation?: string
|
||||||
|
difficulty_level?: string
|
||||||
|
points?: number
|
||||||
|
options?: QuestionOption[]
|
||||||
|
short_answers?: string[]
|
||||||
type: "MCQ" | "TRUE_FALSE" | "SHORT"
|
type: "MCQ" | "TRUE_FALSE" | "SHORT"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -352,6 +359,11 @@ export interface UpdatePracticeQuestionRequest {
|
||||||
sample_answer_voice_prompt?: string
|
sample_answer_voice_prompt?: string
|
||||||
sample_answer: string
|
sample_answer: string
|
||||||
tips?: string
|
tips?: string
|
||||||
|
explanation?: string
|
||||||
|
difficulty_level?: string
|
||||||
|
points?: number
|
||||||
|
options?: QuestionOption[]
|
||||||
|
short_answers?: string[]
|
||||||
type: "MCQ" | "TRUE_FALSE" | "SHORT"
|
type: "MCQ" | "TRUE_FALSE" | "SHORT"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -375,7 +387,65 @@ export interface QuestionSet {
|
||||||
|
|
||||||
export interface GetQuestionSetsResponse {
|
export interface GetQuestionSetsResponse {
|
||||||
message: string
|
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
|
success: boolean
|
||||||
status_code: number
|
status_code: number
|
||||||
metadata: unknown
|
metadata: unknown
|
||||||
|
|
@ -396,7 +466,7 @@ export interface CreateQuestionSetRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddQuestionToSetRequest {
|
export interface AddQuestionToSetRequest {
|
||||||
display_order: number
|
display_order?: number
|
||||||
question_id: number
|
question_id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -409,15 +479,16 @@ export interface QuestionOption {
|
||||||
export interface CreateQuestionRequest {
|
export interface CreateQuestionRequest {
|
||||||
question_text: string
|
question_text: string
|
||||||
question_type: string
|
question_type: string
|
||||||
difficulty_level: string
|
difficulty_level?: string
|
||||||
points: number
|
points?: number
|
||||||
tips?: string
|
tips?: string
|
||||||
explanation?: string
|
explanation?: string
|
||||||
status?: string
|
status?: string
|
||||||
options?: QuestionOption[]
|
options?: QuestionOption[]
|
||||||
voice_prompt?: string
|
voice_prompt?: string
|
||||||
sample_answer_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 {
|
export interface CreateQuestionResponse {
|
||||||
|
|
@ -430,6 +501,52 @@ export interface CreateQuestionResponse {
|
||||||
metadata: unknown
|
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 {
|
export interface CreateQuestionSetResponse {
|
||||||
message: string
|
message: string
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
|
|
@ -18,3 +18,19 @@ export interface LearnerCourseProgressResponse {
|
||||||
message: string
|
message: string
|
||||||
data: LearnerCourseProgressItem[]
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ export interface User {
|
||||||
nickName: string
|
nickName: string
|
||||||
email: string
|
email: string
|
||||||
phoneNumber: string
|
phoneNumber: string
|
||||||
|
role: string
|
||||||
region: string
|
region: string
|
||||||
country: string
|
country: string
|
||||||
lastLogin: string | null
|
lastLogin: string | null
|
||||||
|
|
@ -63,6 +64,7 @@ export const mapUserApiToUser = (u: UserApiDTO): User => ({
|
||||||
nickName: u.nick_name,
|
nickName: u.nick_name,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
phoneNumber: u.phone_number ?? "",
|
phoneNumber: u.phone_number ?? "",
|
||||||
|
role: u.role,
|
||||||
region: u.region,
|
region: u.region,
|
||||||
country: u.country,
|
country: u.country,
|
||||||
lastLogin: null,
|
lastLogin: null,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user