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,
|
||||
UpdatePracticeRequest,
|
||||
UpdatePracticeStatusRequest,
|
||||
GetPracticeQuestionsResponse,
|
||||
CreatePracticeQuestionRequest,
|
||||
UpdatePracticeQuestionRequest,
|
||||
GetProgramsResponse,
|
||||
|
|
@ -31,11 +30,17 @@ import type {
|
|||
UpdateModuleRequest,
|
||||
UpdateModuleStatusRequest,
|
||||
GetQuestionSetsResponse,
|
||||
GetQuestionSetsParams,
|
||||
GetQuestionSetDetailResponse,
|
||||
GetQuestionSetQuestionsResponse,
|
||||
CreateQuestionSetRequest,
|
||||
CreateQuestionSetResponse,
|
||||
AddQuestionToSetRequest,
|
||||
CreateQuestionRequest,
|
||||
CreateQuestionResponse,
|
||||
GetQuestionDetailResponse,
|
||||
GetQuestionsParams,
|
||||
GetQuestionsResponse,
|
||||
CreateVimeoVideoRequest,
|
||||
CreateCourseCategoryRequest,
|
||||
GetSubCoursePrerequisitesResponse,
|
||||
|
|
@ -119,7 +124,7 @@ export const deletePractice = (practiceId: number) =>
|
|||
|
||||
// Practice Questions APIs
|
||||
export const getPracticeQuestions = (practiceId: number) =>
|
||||
http.get<GetPracticeQuestionsResponse>(`/course-management/practices/${practiceId}/questions`)
|
||||
http.get<GetQuestionSetQuestionsResponse>(`/question-sets/${practiceId}/questions`)
|
||||
|
||||
export const createPracticeQuestion = (data: CreatePracticeQuestionRequest) =>
|
||||
http.post("/course-management/practice-questions", data)
|
||||
|
|
@ -187,11 +192,20 @@ export const getPracticesByModule = (moduleId: number) =>
|
|||
http.get<GetPracticesResponse>(`/course-management/modules/${moduleId}/practices`)
|
||||
|
||||
// Question Sets API
|
||||
export const getQuestionSets = (params?: GetQuestionSetsParams) =>
|
||||
http.get<GetQuestionSetsResponse>("/question-sets", { params })
|
||||
|
||||
export const getQuestionSetsByOwner = (ownerType: string, ownerId: number) =>
|
||||
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
|
||||
params: { owner_type: ownerType, owner_id: ownerId },
|
||||
})
|
||||
|
||||
export const getQuestionSetById = (questionSetId: number) =>
|
||||
http.get<GetQuestionSetDetailResponse>(`/question-sets/${questionSetId}`)
|
||||
|
||||
export const getQuestionSetQuestions = (questionSetId: number) =>
|
||||
http.get<GetQuestionSetQuestionsResponse>(`/question-sets/${questionSetId}/questions`)
|
||||
|
||||
export const createQuestionSet = (data: CreateQuestionSetRequest) =>
|
||||
http.post<CreateQuestionSetResponse>("/question-sets", data)
|
||||
|
||||
|
|
@ -201,6 +215,18 @@ export const addQuestionToSet = (questionSetId: number, data: AddQuestionToSetRe
|
|||
export const createQuestion = (data: CreateQuestionRequest) =>
|
||||
http.post<CreateQuestionResponse>("/questions", data)
|
||||
|
||||
export const getQuestions = (params: GetQuestionsParams) =>
|
||||
http.get<GetQuestionsResponse>("/questions", { params })
|
||||
|
||||
export const getQuestionById = (questionId: number) =>
|
||||
http.get<GetQuestionDetailResponse>(`/questions/${questionId}`)
|
||||
|
||||
export const deleteQuestion = (questionId: number) =>
|
||||
http.delete(`/questions/${questionId}`)
|
||||
|
||||
export const updateQuestion = (questionId: number, data: CreateQuestionRequest) =>
|
||||
http.put(`/questions/${questionId}`, data)
|
||||
|
||||
export const deleteQuestionSet = (questionSetId: number) =>
|
||||
http.delete(`/question-sets/${questionSetId}`)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import http from "./http"
|
||||
import type { LearnerCourseProgressResponse } from "../types/progress.types"
|
||||
import type {
|
||||
LearnerCourseProgressResponse,
|
||||
LearnerCourseProgressSummaryResponse,
|
||||
} from "../types/progress.types"
|
||||
|
||||
export const getAdminLearnerCourseProgress = (userId: number, courseId: number) =>
|
||||
http.get<LearnerCourseProgressResponse>(`/admin/users/${userId}/progress/courses/${courseId}`)
|
||||
|
||||
export const getAdminLearnerCourseProgressSummary = (userId: number, courseId: number) =>
|
||||
http.get<LearnerCourseProgressSummaryResponse>(
|
||||
`/admin/users/${userId}/progress/courses/${courseId}/summary`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useNavigate, useParams } from "react-router-dom"
|
||||
import { ArrowLeft, Plus, X } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -7,30 +7,41 @@ import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/ca
|
|||
import { Input } from "../../components/ui/input"
|
||||
import { Textarea } from "../../components/ui/textarea"
|
||||
import { Select } from "../../components/ui/select"
|
||||
import { createQuestion, getQuestionById, updateQuestion } from "../../api/courses.api"
|
||||
|
||||
type QuestionType = "multiple-choice" | "short-answer" | "true-false"
|
||||
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
|
||||
type Difficulty = "EASY" | "MEDIUM" | "HARD"
|
||||
type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE"
|
||||
|
||||
interface Question {
|
||||
id: string
|
||||
id?: number
|
||||
question: string
|
||||
type: QuestionType
|
||||
options: string[]
|
||||
correctAnswer: string
|
||||
points: number
|
||||
category?: string
|
||||
difficulty?: string
|
||||
difficulty: Difficulty
|
||||
status: QuestionStatus
|
||||
tips: string
|
||||
explanation: string
|
||||
voicePrompt: string
|
||||
sampleAnswerVoicePrompt: string
|
||||
audioCorrectAnswerText: string
|
||||
}
|
||||
|
||||
// Mock data for editing
|
||||
const mockQuestion: Question = {
|
||||
id: "1",
|
||||
const initialForm: Question = {
|
||||
question: "",
|
||||
type: "multiple-choice",
|
||||
type: "MCQ",
|
||||
options: ["", "", "", ""],
|
||||
correctAnswer: "",
|
||||
points: 10,
|
||||
category: "",
|
||||
difficulty: "",
|
||||
points: 1,
|
||||
difficulty: "EASY",
|
||||
status: "PUBLISHED",
|
||||
tips: "",
|
||||
explanation: "",
|
||||
voicePrompt: "",
|
||||
sampleAnswerVoicePrompt: "",
|
||||
audioCorrectAnswerText: "",
|
||||
}
|
||||
|
||||
export function AddQuestionPage() {
|
||||
|
|
@ -38,36 +49,83 @@ export function AddQuestionPage() {
|
|||
const { id } = useParams<{ id?: string }>()
|
||||
const isEditing = !!id
|
||||
|
||||
const [formData, setFormData] = useState<Question>(
|
||||
isEditing
|
||||
? mockQuestion // In a real app, fetch the question by id
|
||||
: {
|
||||
id: Date.now().toString(),
|
||||
question: "",
|
||||
type: "multiple-choice",
|
||||
options: ["", "", "", ""],
|
||||
correctAnswer: "",
|
||||
points: 10,
|
||||
category: "",
|
||||
difficulty: "",
|
||||
},
|
||||
)
|
||||
const [formData, setFormData] = useState<Question>(initialForm)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const loadQuestion = async () => {
|
||||
if (!isEditing || !id) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getQuestionById(Number(id))
|
||||
const q = res.data.data
|
||||
const mappedType: QuestionType =
|
||||
q.question_type === "MCQ" ||
|
||||
q.question_type === "TRUE_FALSE" ||
|
||||
q.question_type === "SHORT_ANSWER" ||
|
||||
q.question_type === "AUDIO"
|
||||
? q.question_type
|
||||
: "MCQ"
|
||||
const shortAnswer = Array.isArray(q.short_answers) && q.short_answers.length > 0
|
||||
? typeof q.short_answers[0] === "string"
|
||||
? String(q.short_answers[0] || "")
|
||||
: String((q.short_answers[0] as { acceptable_answer?: string }).acceptable_answer || "")
|
||||
: ""
|
||||
setFormData({
|
||||
id: q.id,
|
||||
question: q.question_text || "",
|
||||
type: mappedType,
|
||||
options: (q.options ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.option_order - b.option_order)
|
||||
.map((o) => o.option_text) || ["", "", "", ""],
|
||||
correctAnswer:
|
||||
mappedType === "SHORT_ANSWER"
|
||||
? shortAnswer
|
||||
: mappedType === "AUDIO"
|
||||
? q.audio_correct_answer_text || ""
|
||||
: (q.options ?? []).find((o) => o.is_correct)?.option_text || "",
|
||||
points: q.points ?? 1,
|
||||
difficulty:
|
||||
q.difficulty_level === "EASY" || q.difficulty_level === "MEDIUM" || q.difficulty_level === "HARD"
|
||||
? q.difficulty_level
|
||||
: "EASY",
|
||||
status:
|
||||
q.status === "DRAFT" || q.status === "PUBLISHED" || q.status === "INACTIVE"
|
||||
? q.status
|
||||
: "PUBLISHED",
|
||||
tips: q.tips || "",
|
||||
explanation: q.explanation || "",
|
||||
voicePrompt: q.voice_prompt || "",
|
||||
sampleAnswerVoicePrompt: q.sample_answer_voice_prompt || "",
|
||||
audioCorrectAnswerText: q.audio_correct_answer_text || "",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to load question:", error)
|
||||
toast.error("Failed to load question details")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
loadQuestion()
|
||||
}, [isEditing, id])
|
||||
|
||||
const handleTypeChange = (type: QuestionType) => {
|
||||
setFormData((prev) => {
|
||||
if (type === "true-false") {
|
||||
if (type === "TRUE_FALSE") {
|
||||
return {
|
||||
...prev,
|
||||
type,
|
||||
options: ["True", "False"],
|
||||
correctAnswer: prev.correctAnswer === "True" || prev.correctAnswer === "False" ? prev.correctAnswer : "",
|
||||
}
|
||||
} else if (type === "short-answer") {
|
||||
} else if (type === "SHORT_ANSWER" || type === "AUDIO") {
|
||||
return {
|
||||
...prev,
|
||||
type,
|
||||
options: [],
|
||||
correctAnswer: "",
|
||||
correctAnswer: type === "AUDIO" ? prev.audioCorrectAnswerText : prev.correctAnswer,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
|
|
@ -101,7 +159,7 @@ export function AddQuestionPage() {
|
|||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Validation
|
||||
|
|
@ -112,14 +170,14 @@ export function AddQuestionPage() {
|
|||
return
|
||||
}
|
||||
|
||||
if (formData.type === "multiple-choice" || formData.type === "true-false") {
|
||||
if (formData.type === "MCQ" || formData.type === "TRUE_FALSE") {
|
||||
if (!formData.correctAnswer) {
|
||||
toast.error("Missing correct answer", {
|
||||
description: "Select the correct answer for this question.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (formData.type === "multiple-choice") {
|
||||
if (formData.type === "MCQ") {
|
||||
const hasEmptyOptions = formData.options.some((opt) => !opt.trim())
|
||||
if (hasEmptyOptions) {
|
||||
toast.error("Incomplete options", {
|
||||
|
|
@ -128,23 +186,74 @@ export function AddQuestionPage() {
|
|||
return
|
||||
}
|
||||
}
|
||||
} else if (formData.type === "short-answer") {
|
||||
} else if (formData.type === "SHORT_ANSWER") {
|
||||
if (!formData.correctAnswer.trim()) {
|
||||
toast.error("Missing correct answer", {
|
||||
description: "Enter the expected correct answer.",
|
||||
})
|
||||
return
|
||||
}
|
||||
} else if (formData.type === "AUDIO") {
|
||||
if (!formData.voicePrompt.trim() || !formData.sampleAnswerVoicePrompt.trim() || !formData.audioCorrectAnswerText.trim()) {
|
||||
toast.error("Missing audio fields", {
|
||||
description: "Voice prompt, sample answer voice prompt, and audio correct answer text are required for AUDIO questions.",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// In a real app, save the question here
|
||||
console.log("Saving question:", formData)
|
||||
toast.success(isEditing ? "Question updated" : "Question created", {
|
||||
description: isEditing
|
||||
? "The question has been updated successfully."
|
||||
: "Your new question has been created.",
|
||||
})
|
||||
navigate("/content/questions")
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const optionsPayload =
|
||||
formData.type === "MCQ" || formData.type === "TRUE_FALSE"
|
||||
? formData.options
|
||||
.filter((o) => o.trim())
|
||||
.map((optionText, index) => ({
|
||||
option_text: optionText.trim(),
|
||||
option_order: index + 1,
|
||||
is_correct: optionText === formData.correctAnswer,
|
||||
}))
|
||||
: undefined
|
||||
const shortAnswersPayload =
|
||||
formData.type === "SHORT_ANSWER"
|
||||
? [
|
||||
{ acceptable_answer: formData.correctAnswer.trim(), match_type: "EXACT" as const },
|
||||
{ acceptable_answer: formData.correctAnswer.trim(), match_type: "CASE_INSENSITIVE" as const },
|
||||
]
|
||||
: undefined
|
||||
const payload = {
|
||||
question_text: formData.question,
|
||||
question_type: formData.type,
|
||||
status: formData.status,
|
||||
difficulty_level: formData.difficulty,
|
||||
points: formData.points,
|
||||
tips: formData.tips || undefined,
|
||||
explanation: formData.explanation || undefined,
|
||||
options: optionsPayload,
|
||||
short_answers: shortAnswersPayload,
|
||||
voice_prompt: formData.type === "AUDIO" ? formData.voicePrompt : formData.voicePrompt || undefined,
|
||||
sample_answer_voice_prompt:
|
||||
formData.type === "AUDIO" ? formData.sampleAnswerVoicePrompt : formData.sampleAnswerVoicePrompt || undefined,
|
||||
audio_correct_answer_text:
|
||||
formData.type === "AUDIO" ? formData.audioCorrectAnswerText : undefined,
|
||||
}
|
||||
if (isEditing && id) {
|
||||
await updateQuestion(Number(id), payload)
|
||||
} else {
|
||||
await createQuestion(payload)
|
||||
}
|
||||
toast.success(isEditing ? "Question updated" : "Question created", {
|
||||
description: isEditing
|
||||
? "The question has been updated successfully."
|
||||
: "Your new question has been created.",
|
||||
})
|
||||
navigate("/content/questions")
|
||||
} catch (error) {
|
||||
console.error("Failed to save question:", error)
|
||||
toast.error("Failed to save question")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -170,6 +279,11 @@ export function AddQuestionPage() {
|
|||
</div>
|
||||
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{loading && (
|
||||
<Card className="mb-4 border border-grayScale-200">
|
||||
<CardContent className="py-4 text-sm text-grayScale-500">Loading question details...</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Card className="shadow-sm border border-grayScale-100 rounded-xl">
|
||||
<CardHeader className="pb-2">
|
||||
|
|
@ -185,9 +299,10 @@ export function AddQuestionPage() {
|
|||
value={formData.type}
|
||||
onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
|
||||
>
|
||||
<option value="multiple-choice">Multiple Choice</option>
|
||||
<option value="short-answer">Short Answer</option>
|
||||
<option value="true-false">True/False</option>
|
||||
<option value="MCQ">Multiple Choice</option>
|
||||
<option value="TRUE_FALSE">True/False</option>
|
||||
<option value="SHORT_ANSWER">Short Answer</option>
|
||||
<option value="AUDIO">Audio</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
|
@ -209,7 +324,7 @@ export function AddQuestionPage() {
|
|||
</div>
|
||||
|
||||
{/* Options for Multiple Choice */}
|
||||
{(formData.type === "multiple-choice" || formData.type === "true-false") && (
|
||||
{(formData.type === "MCQ" || formData.type === "TRUE_FALSE") && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Options
|
||||
|
|
@ -224,10 +339,10 @@ export function AddQuestionPage() {
|
|||
value={option}
|
||||
onChange={(e) => handleOptionChange(index, e.target.value)}
|
||||
placeholder={`Option ${index + 1}`}
|
||||
disabled={formData.type === "true-false"}
|
||||
disabled={formData.type === "TRUE_FALSE"}
|
||||
required
|
||||
/>
|
||||
{formData.type === "multiple-choice" && formData.options.length > 2 && (
|
||||
{formData.type === "MCQ" && formData.options.length > 2 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
|
@ -240,7 +355,7 @@ export function AddQuestionPage() {
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
{formData.type === "multiple-choice" && (
|
||||
{formData.type === "MCQ" && (
|
||||
<Button type="button" variant="outline" onClick={addOption} className="w-full mt-1 border-dashed border-grayScale-200 text-grayScale-400 hover:text-brand-500 hover:border-brand-500/30">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Option
|
||||
|
|
@ -255,9 +370,9 @@ export function AddQuestionPage() {
|
|||
{/* Correct Answer */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Correct Answer
|
||||
{formData.type === "AUDIO" ? "Audio Correct Answer Text" : "Correct Answer"}
|
||||
</label>
|
||||
{formData.type === "multiple-choice" || formData.type === "true-false" ? (
|
||||
{formData.type === "MCQ" || formData.type === "TRUE_FALSE" ? (
|
||||
<Select
|
||||
value={formData.correctAnswer}
|
||||
onChange={(e) =>
|
||||
|
|
@ -274,10 +389,14 @@ export function AddQuestionPage() {
|
|||
</Select>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder="Enter the correct answer..."
|
||||
value={formData.correctAnswer}
|
||||
placeholder={formData.type === "AUDIO" ? "Enter audio correct answer text..." : "Enter the correct answer..."}
|
||||
value={formData.type === "AUDIO" ? formData.audioCorrectAnswerText : formData.correctAnswer}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
|
||||
setFormData((prev) =>
|
||||
formData.type === "AUDIO"
|
||||
? { ...prev, audioCorrectAnswerText: e.target.value }
|
||||
: { ...prev, correctAnswer: e.target.value },
|
||||
)
|
||||
}
|
||||
rows={2}
|
||||
required
|
||||
|
|
@ -300,7 +419,7 @@ export function AddQuestionPage() {
|
|||
min="1"
|
||||
value={formData.points}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 0 }))
|
||||
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 1 }))
|
||||
}
|
||||
required
|
||||
/>
|
||||
|
|
@ -312,27 +431,74 @@ export function AddQuestionPage() {
|
|||
Difficulty (Optional)
|
||||
</label>
|
||||
<Select
|
||||
value={formData.difficulty || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value }))}
|
||||
value={formData.difficulty}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value as Difficulty }))}
|
||||
>
|
||||
<option value="">Select difficulty</option>
|
||||
<option value="Easy">Easy</option>
|
||||
<option value="Medium">Medium</option>
|
||||
<option value="Hard">Hard</option>
|
||||
<option value="EASY">Easy</option>
|
||||
<option value="MEDIUM">Medium</option>
|
||||
<option value="HARD">Hard</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label htmlFor="category" className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Category (Optional)
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Status
|
||||
</label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, status: e.target.value as QuestionStatus }))}
|
||||
>
|
||||
<option value="DRAFT">Draft</option>
|
||||
<option value="PUBLISHED">Published</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{(formData.type === "AUDIO" || formData.type === "SHORT_ANSWER") && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
|
||||
</label>
|
||||
<Textarea
|
||||
value={formData.voicePrompt}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, voicePrompt: e.target.value }))}
|
||||
rows={2}
|
||||
placeholder="Please say your answer..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Sample Answer Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
|
||||
</label>
|
||||
<Textarea
|
||||
value={formData.sampleAnswerVoicePrompt}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, sampleAnswerVoicePrompt: e.target.value }))}
|
||||
rows={2}
|
||||
placeholder="Sample spoken answer..."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Tips (Optional)</label>
|
||||
<Input
|
||||
id="category"
|
||||
placeholder="e.g., Programming, Geography"
|
||||
value={formData.category || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, category: e.target.value }))}
|
||||
value={formData.tips}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))}
|
||||
placeholder="Helpful tip for learners"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Explanation (Optional)</label>
|
||||
<Textarea
|
||||
value={formData.explanation}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, explanation: e.target.value }))}
|
||||
rows={2}
|
||||
placeholder="Explain why the answer is correct"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -341,7 +507,7 @@ export function AddQuestionPage() {
|
|||
<Button type="button" variant="outline" onClick={() => navigate("/content/questions")} className="w-full sm:w-auto hover:bg-grayScale-50">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="bg-brand-500 hover:bg-brand-600 text-white w-full sm:w-auto shadow-sm hover:shadow-md transition-all">
|
||||
<Button type="submit" disabled={submitting || loading} className="bg-brand-500 hover:bg-brand-600 text-white w-full sm:w-auto shadow-sm hover:shadow-md transition-all">
|
||||
{isEditing ? "Update Question" : "Create Question"}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,299 +1,267 @@
|
|||
import { useState } from "react"
|
||||
import { Plus, Edit, Trash2 } from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { RefreshCw } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Card } from "../../components/ui/card"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Textarea } from "../../components/ui/textarea"
|
||||
import { Select } from "../../components/ui/select"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../../components/ui/dialog"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table"
|
||||
import { Badge } from "../../components/ui/badge"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../../components/ui/dialog"
|
||||
import { getQuestionSetById, getQuestionSets } from "../../api/courses.api"
|
||||
import type { QuestionSet, QuestionSetDetail } from "../../types/course.types"
|
||||
|
||||
const mockLeaders = [
|
||||
{ id: "1", name: "John Doe", role: "CEO" },
|
||||
{ id: "2", name: "Jane Smith", role: "COO" },
|
||||
]
|
||||
|
||||
const mockMembers = [
|
||||
{ id: "1", name: "John Doe", role: "Member" },
|
||||
{ id: "2", name: "Jane Smith", role: "Member" },
|
||||
]
|
||||
const statusColor: Record<string, string> = {
|
||||
PUBLISHED: "bg-green-100 text-green-700",
|
||||
DRAFT: "bg-amber-100 text-amber-700",
|
||||
ARCHIVED: "bg-grayScale-200 text-grayScale-600",
|
||||
}
|
||||
|
||||
export function PracticeDetailsPage() {
|
||||
const [isMemberModalOpen, setIsMemberModalOpen] = useState(false)
|
||||
const [isLeaderModalOpen, setIsLeaderModalOpen] = useState(false)
|
||||
const [memberName, setMemberName] = useState("")
|
||||
const [memberRole, setMemberRole] = useState("")
|
||||
const [leaderName, setLeaderName] = useState("")
|
||||
const [leaderRole, setLeaderRole] = useState("")
|
||||
const [practices, setPractices] = useState<QuestionSet[]>([])
|
||||
const [selectedPracticeId, setSelectedPracticeId] = useState<number | null>(null)
|
||||
const [selectedPracticeDetail, setSelectedPracticeDetail] = useState<QuestionSetDetail | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [loadingList, setLoadingList] = useState(false)
|
||||
const [loadingDetail, setLoadingDetail] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [ownerTypeFilter, setOwnerTypeFilter] = useState("all")
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
type: "",
|
||||
street: "",
|
||||
city: "",
|
||||
state: "",
|
||||
zipCode: "",
|
||||
})
|
||||
const fetchPractices = useCallback(async () => {
|
||||
setLoadingList(true)
|
||||
try {
|
||||
const res = await getQuestionSets({ set_type: "PRACTICE" })
|
||||
const payload = res.data?.data as unknown
|
||||
let sets: QuestionSet[] = []
|
||||
if (Array.isArray(payload)) {
|
||||
sets = payload as QuestionSet[]
|
||||
} else if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
Array.isArray((payload as { question_sets?: unknown[] }).question_sets)
|
||||
) {
|
||||
sets = (payload as { question_sets: QuestionSet[] }).question_sets
|
||||
}
|
||||
setPractices(sets)
|
||||
if (sets.length > 0) {
|
||||
setSelectedPracticeId((prev) => prev ?? sets[0].id)
|
||||
} else {
|
||||
setSelectedPracticeId(null)
|
||||
setSelectedPracticeDetail(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch practices:", error)
|
||||
setPractices([])
|
||||
setSelectedPracticeId(null)
|
||||
setSelectedPracticeDetail(null)
|
||||
} finally {
|
||||
setLoadingList(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAddMember = () => {
|
||||
console.log("Add member:", { memberName, memberRole })
|
||||
setIsMemberModalOpen(false)
|
||||
setMemberName("")
|
||||
setMemberRole("")
|
||||
}
|
||||
const fetchPracticeDetail = useCallback(async (practiceId: number) => {
|
||||
setLoadingDetail(true)
|
||||
try {
|
||||
const res = await getQuestionSetById(practiceId)
|
||||
setSelectedPracticeDetail(res.data?.data ?? null)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch practice detail:", error)
|
||||
setSelectedPracticeDetail(null)
|
||||
} finally {
|
||||
setLoadingDetail(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAddLeader = () => {
|
||||
console.log("Add leader:", { leaderName, leaderRole })
|
||||
setIsLeaderModalOpen(false)
|
||||
setLeaderName("")
|
||||
setLeaderRole("")
|
||||
}
|
||||
useEffect(() => {
|
||||
fetchPractices()
|
||||
}, [fetchPractices])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPracticeId) {
|
||||
fetchPracticeDetail(selectedPracticeId)
|
||||
}
|
||||
}, [selectedPracticeId, fetchPracticeDetail])
|
||||
|
||||
const filteredPractices = useMemo(() => {
|
||||
return practices.filter((practice) => {
|
||||
const matchesSearch =
|
||||
!searchQuery.trim() ||
|
||||
practice.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(practice.description || "").toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
String(practice.id).includes(searchQuery) ||
|
||||
String(practice.owner_id).includes(searchQuery)
|
||||
const matchesStatus = statusFilter === "all" || practice.status === statusFilter
|
||||
const matchesOwnerType = ownerTypeFilter === "all" || practice.owner_type === ownerTypeFilter
|
||||
return matchesSearch && matchesStatus && matchesOwnerType
|
||||
})
|
||||
}, [practices, searchQuery, statusFilter, ownerTypeFilter])
|
||||
|
||||
const totalCount = useMemo(() => filteredPractices.length, [filteredPractices])
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Practice Management</h1>
|
||||
<p className="mt-1 text-sm text-grayScale-400">Manage your practice details, leadership, and members</p>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Practice Management</h1>
|
||||
<p className="mt-1 text-sm text-grayScale-400">
|
||||
Browse all practice question sets and view their details.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={fetchPractices} disabled={loadingList}>
|
||||
<RefreshCw className={`h-4 w-4 ${loadingList ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
|
||||
{/* Practice Leadership */}
|
||||
<Card className="border-grayScale-200 p-6 shadow-sm">
|
||||
<div className="mb-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-600">Practice Leadership</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setIsLeaderModalOpen(true)}
|
||||
className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add New Leader
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{mockLeaders.map((leader) => (
|
||||
<div
|
||||
key={leader.id}
|
||||
className="group flex items-center justify-between rounded-xl border border-grayScale-200 p-3.5 transition-all hover:border-grayScale-300 hover:bg-grayScale-50/50 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-9 w-9 place-items-center rounded-full bg-brand-100 text-sm font-semibold text-brand-600">
|
||||
{leader.name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-grayScale-600">{leader.name}</p>
|
||||
<p className="text-xs text-grayScale-400">{leader.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-grayScale-600">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Practice Details */}
|
||||
<Card className="border-grayScale-200 p-6 shadow-sm">
|
||||
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">Practice Details</h2>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Practice Name
|
||||
</label>
|
||||
<Card className="shadow-soft">
|
||||
<CardHeader className="border-b border-grayScale-200 pb-4">
|
||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||
Practices ({totalCount})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-5">
|
||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Enter practice name"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by title, description, practice ID, or owner ID..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Practice Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Enter practice description"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Practice Type
|
||||
</label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
>
|
||||
<option value="">Select practice type</option>
|
||||
<option value="online">Online</option>
|
||||
<option value="offline">Offline</option>
|
||||
<option value="hybrid">Hybrid</option>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="PUBLISHED">PUBLISHED</option>
|
||||
<option value="DRAFT">DRAFT</option>
|
||||
<option value="ARCHIVED">ARCHIVED</option>
|
||||
</Select>
|
||||
<Select value={ownerTypeFilter} onChange={(e) => setOwnerTypeFilter(e.target.value)}>
|
||||
<option value="all">All Owner Types</option>
|
||||
<option value="SUB_COURSE">SUB_COURSE</option>
|
||||
<option value="COURSE">COURSE</option>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearchQuery("")
|
||||
setStatusFilter("all")
|
||||
setOwnerTypeFilter("all")
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Practice Address
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={formData.street}
|
||||
onChange={(e) => setFormData({ ...formData, street: e.target.value })}
|
||||
placeholder="Street"
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Input
|
||||
value={formData.city}
|
||||
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
||||
placeholder="City"
|
||||
/>
|
||||
<Input
|
||||
value={formData.state}
|
||||
onChange={(e) => setFormData({ ...formData, state: e.target.value })}
|
||||
placeholder="State"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={formData.zipCode}
|
||||
onChange={(e) => setFormData({ ...formData, zipCode: e.target.value })}
|
||||
placeholder="Zip Code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button className="w-full bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Practice Members */}
|
||||
<Card className="border-grayScale-200 p-6 shadow-sm">
|
||||
<div className="mb-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-600">Practice Members</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setIsMemberModalOpen(true)}
|
||||
className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add New Member
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{mockMembers.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="group flex items-center justify-between rounded-xl border border-grayScale-200 p-3.5 transition-all hover:border-grayScale-300 hover:bg-grayScale-50/50 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-9 w-9 place-items-center rounded-full bg-brand-100 text-sm font-semibold text-brand-600">
|
||||
{member.name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-grayScale-600">{member.name}</p>
|
||||
<p className="text-xs text-grayScale-400">{member.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-grayScale-600">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{loadingList ? (
|
||||
<div className="py-16 text-center text-sm text-grayScale-500">Loading practices...</div>
|
||||
) : filteredPractices.length === 0 ? (
|
||||
<div className="rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center text-sm text-grayScale-500">
|
||||
No practice sets found.
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
|
||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">Title</TableHead>
|
||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">Owner</TableHead>
|
||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">Status</TableHead>
|
||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPractices.map((practice, index) => (
|
||||
<TableRow
|
||||
key={practice.id}
|
||||
onClick={() => {
|
||||
setSelectedPracticeId(practice.id)
|
||||
setDetailOpen(true)
|
||||
}}
|
||||
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
|
||||
selectedPracticeId === practice.id
|
||||
? "bg-brand-100/40"
|
||||
: index % 2 === 0
|
||||
? "bg-white"
|
||||
: "bg-grayScale-100/50"
|
||||
}`}
|
||||
>
|
||||
<TableCell className="max-w-md py-3.5">
|
||||
<p className="truncate text-sm font-medium text-grayScale-700">{practice.title}</p>
|
||||
<p className="mt-1 truncate text-xs text-grayScale-500">{practice.description || "—"}</p>
|
||||
</TableCell>
|
||||
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
|
||||
{practice.owner_type} #{practice.owner_id}
|
||||
</TableCell>
|
||||
<TableCell className="py-3.5">
|
||||
<Badge className={statusColor[practice.status] || "bg-grayScale-200 text-grayScale-600"}>
|
||||
{practice.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
|
||||
{practice.created_at}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add Member Modal */}
|
||||
<Dialog open={isMemberModalOpen} onOpenChange={setIsMemberModalOpen}>
|
||||
<DialogContent className="sm:rounded-xl">
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Member</DialogTitle>
|
||||
<DialogTitle>Practice Detail</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5 py-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Member Name
|
||||
</label>
|
||||
<Input
|
||||
value={memberName}
|
||||
onChange={(e) => setMemberName(e.target.value)}
|
||||
placeholder="Enter member name"
|
||||
/>
|
||||
{!selectedPracticeId ? (
|
||||
<p className="text-sm text-grayScale-500">Select a practice from the list to view details.</p>
|
||||
) : loadingDetail ? (
|
||||
<p className="text-sm text-grayScale-500">Loading detail...</p>
|
||||
) : !selectedPracticeDetail ? (
|
||||
<p className="text-sm text-grayScale-500">Failed to load practice detail.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Title</p>
|
||||
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.title}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Set Type</p>
|
||||
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.set_type}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3 sm:col-span-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Description</p>
|
||||
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.description || "—"}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Owner</p>
|
||||
<p className="mt-1 text-sm text-grayScale-700">
|
||||
{selectedPracticeDetail.owner_type} #{selectedPracticeDetail.owner_id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Status</p>
|
||||
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.status}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Question Count</p>
|
||||
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.question_count ?? 0}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Created At</p>
|
||||
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.created_at}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Member Role
|
||||
</label>
|
||||
<Input
|
||||
value={memberRole}
|
||||
onChange={(e) => setMemberRole(e.target.value)}
|
||||
placeholder="Enter member role"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsMemberModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAddMember} className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
|
||||
Add Member
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Add Leader Modal */}
|
||||
<Dialog open={isLeaderModalOpen} onOpenChange={setIsLeaderModalOpen}>
|
||||
<DialogContent className="sm:rounded-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Leader</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5 py-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Leader Name
|
||||
</label>
|
||||
<Input
|
||||
value={leaderName}
|
||||
onChange={(e) => setLeaderName(e.target.value)}
|
||||
placeholder="Enter leader name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Leader Role
|
||||
</label>
|
||||
<Input
|
||||
value={leaderRole}
|
||||
onChange={(e) => setLeaderRole(e.target.value)}
|
||||
placeholder="Enter leader role"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsLeaderModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAddLeader} className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
|
||||
Add Leader
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,10 +1,11 @@
|
|||
import { useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Plus, Search, Edit, Trash2, HelpCircle } from "lucide-react"
|
||||
import { Plus, Search, Edit, Trash2, HelpCircle, X } from "lucide-react"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Select } from "../../components/ui/select"
|
||||
import { Textarea } from "../../components/ui/textarea"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -14,117 +15,294 @@ import {
|
|||
TableRow,
|
||||
} from "../../components/ui/table"
|
||||
import { Badge } from "../../components/ui/badge"
|
||||
import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api"
|
||||
import type { QuestionDetail } from "../../types/course.types"
|
||||
|
||||
type QuestionType = "multiple-choice" | "short-answer" | "true-false"
|
||||
type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
|
||||
type DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD"
|
||||
type StatusFilter = "all" | "DRAFT" | "PUBLISHED" | "INACTIVE"
|
||||
type QuestionTypeEdit = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
|
||||
|
||||
interface Question {
|
||||
id: string
|
||||
question: string
|
||||
type: QuestionType
|
||||
options: string[]
|
||||
correctAnswer: string
|
||||
points: number
|
||||
category?: string
|
||||
difficulty?: string
|
||||
createdAt?: string
|
||||
interface EditOption {
|
||||
option_text: string
|
||||
option_order: number
|
||||
is_correct: boolean
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockQuestions: Question[] = [
|
||||
{
|
||||
id: "1",
|
||||
question: "What is the capital of France?",
|
||||
type: "multiple-choice",
|
||||
options: ["London", "Berlin", "Paris", "Madrid"],
|
||||
correctAnswer: "Paris",
|
||||
points: 10,
|
||||
category: "Geography",
|
||||
difficulty: "Easy",
|
||||
createdAt: "2024-01-15",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
question: "Explain the concept of React hooks in your own words.",
|
||||
type: "short-answer",
|
||||
options: [],
|
||||
correctAnswer: "React hooks are functions that let you use state and other React features in functional components.",
|
||||
points: 20,
|
||||
category: "Programming",
|
||||
difficulty: "Medium",
|
||||
createdAt: "2024-01-16",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
question: "JavaScript is a compiled language.",
|
||||
type: "true-false",
|
||||
options: ["True", "False"],
|
||||
correctAnswer: "False",
|
||||
points: 5,
|
||||
category: "Programming",
|
||||
difficulty: "Easy",
|
||||
createdAt: "2024-01-17",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
question: "Which of the following is a CSS preprocessor?",
|
||||
type: "multiple-choice",
|
||||
options: ["SASS", "HTML", "JavaScript", "Python"],
|
||||
correctAnswer: "SASS",
|
||||
points: 15,
|
||||
category: "Web Development",
|
||||
difficulty: "Medium",
|
||||
createdAt: "2024-01-18",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
question: "TypeScript is a superset of JavaScript.",
|
||||
type: "true-false",
|
||||
options: ["True", "False"],
|
||||
correctAnswer: "True",
|
||||
points: 10,
|
||||
category: "Programming",
|
||||
difficulty: "Easy",
|
||||
createdAt: "2024-01-19",
|
||||
},
|
||||
]
|
||||
|
||||
const typeLabels: Record<QuestionType, string> = {
|
||||
"multiple-choice": "Multiple Choice",
|
||||
"short-answer": "Short Answer",
|
||||
"true-false": "True/False",
|
||||
const typeLabels: Record<string, string> = {
|
||||
MCQ: "Multiple Choice",
|
||||
TRUE_FALSE: "True/False",
|
||||
SHORT_ANSWER: "Short Answer",
|
||||
SHORT: "Short Answer",
|
||||
AUDIO: "Audio",
|
||||
}
|
||||
|
||||
const typeColors: Record<QuestionType, string> = {
|
||||
"multiple-choice": "bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-200",
|
||||
"short-answer": "bg-mint-100 text-green-700 ring-1 ring-inset ring-green-200",
|
||||
"true-false": "bg-brand-100 text-brand-600 ring-1 ring-inset ring-brand-200",
|
||||
const typeColors: Record<string, string> = {
|
||||
MCQ: "bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-200",
|
||||
TRUE_FALSE: "bg-brand-100 text-brand-600 ring-1 ring-inset ring-brand-200",
|
||||
SHORT_ANSWER: "bg-mint-100 text-green-700 ring-1 ring-inset ring-green-200",
|
||||
SHORT: "bg-mint-100 text-green-700 ring-1 ring-inset ring-green-200",
|
||||
AUDIO: "bg-purple-100 text-purple-700 ring-1 ring-inset ring-purple-200",
|
||||
}
|
||||
|
||||
export function QuestionsPage() {
|
||||
const [questions, setQuestions] = useState<Question[]>(mockQuestions)
|
||||
const [questions, setQuestions] = useState<QuestionDetail[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all")
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("all")
|
||||
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
|
||||
const [typeFilter, setTypeFilter] = useState<QuestionTypeFilter>("all")
|
||||
const [difficultyFilter, setDifficultyFilter] = useState<DifficultyFilter>("all")
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[]>([])
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<number | null>(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [detailData, setDetailData] = useState<QuestionDetail | null>(null)
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
const [editQuestionText, setEditQuestionText] = useState("")
|
||||
const [editQuestionType, setEditQuestionType] = useState<QuestionTypeEdit>("MCQ")
|
||||
const [editDifficulty, setEditDifficulty] = useState("EASY")
|
||||
const [editPoints, setEditPoints] = useState(1)
|
||||
const [editStatus, setEditStatus] = useState("PUBLISHED")
|
||||
const [editTips, setEditTips] = useState("")
|
||||
const [editExplanation, setEditExplanation] = useState("")
|
||||
const [editVoicePrompt, setEditVoicePrompt] = useState("")
|
||||
const [editSampleAnswerVoicePrompt, setEditSampleAnswerVoicePrompt] = useState("")
|
||||
const [editShortAnswer, setEditShortAnswer] = useState("")
|
||||
const [editOptions, setEditOptions] = useState<EditOption[]>([
|
||||
{ option_text: "", option_order: 1, is_correct: true },
|
||||
{ option_text: "", option_order: 2, is_correct: false },
|
||||
])
|
||||
|
||||
const filteredQuestions = questions.filter((q) => {
|
||||
const matchesSearch = q.question.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const matchesType = typeFilter === "all" || q.type === typeFilter
|
||||
const matchesCategory = categoryFilter === "all" || q.category === categoryFilter
|
||||
const matchesDifficulty = difficultyFilter === "all" || q.difficulty === difficultyFilter
|
||||
const fetchQuestions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const batchSize = 100
|
||||
let nextOffset = 0
|
||||
let allRows: QuestionDetail[] = []
|
||||
let expectedTotal = Number.POSITIVE_INFINITY
|
||||
|
||||
return matchesSearch && matchesType && matchesCategory && matchesDifficulty
|
||||
})
|
||||
while (allRows.length < expectedTotal) {
|
||||
const res = await getQuestions({
|
||||
question_type: typeFilter === "all" ? undefined : typeFilter,
|
||||
difficulty: difficultyFilter === "all" ? undefined : difficultyFilter,
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
limit: batchSize,
|
||||
offset: nextOffset,
|
||||
})
|
||||
|
||||
const categories = Array.from(new Set(questions.map((q) => q.category).filter(Boolean)))
|
||||
const difficulties = Array.from(new Set(questions.map((q) => q.difficulty).filter(Boolean)))
|
||||
const payload = res.data?.data as unknown
|
||||
const meta = res.data?.metadata as { total_count?: number } | null | undefined
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (window.confirm("Are you sure you want to delete this question?")) {
|
||||
setQuestions(questions.filter((q) => q.id !== id))
|
||||
let chunk: QuestionDetail[] = []
|
||||
let chunkTotal: number | undefined
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
chunk = payload as QuestionDetail[]
|
||||
chunkTotal = meta?.total_count
|
||||
} else if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
Array.isArray((payload as { questions?: unknown[] }).questions)
|
||||
) {
|
||||
const data = payload as { questions: QuestionDetail[]; total_count?: number }
|
||||
chunk = data.questions
|
||||
chunkTotal = data.total_count ?? meta?.total_count
|
||||
}
|
||||
|
||||
allRows = [...allRows, ...chunk]
|
||||
if (typeof chunkTotal === "number" && Number.isFinite(chunkTotal)) {
|
||||
expectedTotal = chunkTotal
|
||||
}
|
||||
|
||||
if (chunk.length < batchSize) break
|
||||
nextOffset += chunk.length
|
||||
}
|
||||
|
||||
setQuestions(allRows)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch questions:", error)
|
||||
setQuestions([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [typeFilter, difficultyFilter, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchQuestions()
|
||||
}, [fetchQuestions])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
setSelectedIds([])
|
||||
}, [searchQuery, pageSize, typeFilter, difficultyFilter, statusFilter])
|
||||
|
||||
const filteredQuestions = useMemo(() => {
|
||||
if (!searchQuery.trim()) return questions
|
||||
return questions.filter((q) =>
|
||||
q.question_text.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
}, [questions, searchQuery])
|
||||
|
||||
const paginatedQuestions = useMemo(() => {
|
||||
const start = (page - 1) * pageSize
|
||||
return filteredQuestions.slice(start, start + pageSize)
|
||||
}, [filteredQuestions, page, pageSize])
|
||||
|
||||
const handleDeleteRequest = (ids: number[]) => {
|
||||
setPendingDeleteIds(ids)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (pendingDeleteIds.length === 0) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await Promise.all(pendingDeleteIds.map((id) => deleteQuestion(id)))
|
||||
setDeleteDialogOpen(false)
|
||||
setPendingDeleteIds([])
|
||||
setSelectedIds((prev) => prev.filter((id) => !pendingDeleteIds.includes(id)))
|
||||
await fetchQuestions()
|
||||
} catch (error) {
|
||||
console.error("Failed to delete question(s):", error)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openDetails = async (id: number) => {
|
||||
setDetailsOpen(true)
|
||||
setDetailLoading(true)
|
||||
setDetailData(null)
|
||||
try {
|
||||
const res = await getQuestionById(id)
|
||||
setDetailData(res.data.data)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch question details:", error)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = async (id: number) => {
|
||||
setEditOpen(true)
|
||||
setDetailLoading(true)
|
||||
setActiveQuestionId(id)
|
||||
try {
|
||||
const res = await getQuestionById(id)
|
||||
const q = res.data.data
|
||||
setDetailData(q)
|
||||
setEditQuestionText(q.question_text || "")
|
||||
setEditQuestionType((q.question_type as QuestionTypeEdit) || "MCQ")
|
||||
setEditDifficulty((q.difficulty_level as string) || "EASY")
|
||||
setEditPoints(q.points ?? 1)
|
||||
setEditStatus(q.status || "PUBLISHED")
|
||||
setEditTips(q.tips || "")
|
||||
setEditExplanation(q.explanation || "")
|
||||
setEditVoicePrompt(q.voice_prompt || "")
|
||||
setEditSampleAnswerVoicePrompt(q.sample_answer_voice_prompt || "")
|
||||
const incomingShort = Array.isArray(q.short_answers) && q.short_answers.length > 0
|
||||
? typeof q.short_answers[0] === "string"
|
||||
? String(q.short_answers[0] || "")
|
||||
: String((q.short_answers[0] as { acceptable_answer?: string }).acceptable_answer || "")
|
||||
: ""
|
||||
setEditShortAnswer(incomingShort)
|
||||
const mappedOptions =
|
||||
(q.options ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.option_order - b.option_order)
|
||||
.map((opt) => ({
|
||||
option_text: opt.option_text,
|
||||
option_order: opt.option_order,
|
||||
is_correct: opt.is_correct,
|
||||
})) || []
|
||||
setEditOptions(
|
||||
mappedOptions.length > 0
|
||||
? mappedOptions
|
||||
: [
|
||||
{ option_text: "", option_order: 1, is_correct: true },
|
||||
{ option_text: "", option_order: 2, is_correct: false },
|
||||
],
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch question for edit:", error)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!activeQuestionId) return
|
||||
setSavingEdit(true)
|
||||
try {
|
||||
const normalizedOptions = editOptions
|
||||
.filter((o) => o.option_text.trim())
|
||||
.map((o, idx) => ({
|
||||
option_text: o.option_text.trim(),
|
||||
option_order: idx + 1,
|
||||
is_correct: o.is_correct,
|
||||
}))
|
||||
await updateQuestion(activeQuestionId, {
|
||||
question_text: editQuestionText,
|
||||
question_type: editQuestionType,
|
||||
difficulty_level: editDifficulty,
|
||||
points: editPoints,
|
||||
status: editStatus,
|
||||
tips: editTips || undefined,
|
||||
explanation: editExplanation || undefined,
|
||||
voice_prompt: editVoicePrompt || undefined,
|
||||
sample_answer_voice_prompt: editSampleAnswerVoicePrompt || undefined,
|
||||
options:
|
||||
editQuestionType === "SHORT_ANSWER"
|
||||
? undefined
|
||||
: normalizedOptions,
|
||||
short_answers:
|
||||
editQuestionType === "SHORT_ANSWER"
|
||||
? [
|
||||
{ acceptable_answer: editShortAnswer, match_type: "EXACT" },
|
||||
{ acceptable_answer: editShortAnswer, match_type: "CASE_INSENSITIVE" },
|
||||
]
|
||||
: undefined,
|
||||
})
|
||||
setEditOpen(false)
|
||||
await fetchQuestions()
|
||||
} catch (error) {
|
||||
console.error("Failed to update question:", error)
|
||||
} finally {
|
||||
setSavingEdit(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleOne = (id: number) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((selectedId) => selectedId !== id) : [...prev, id],
|
||||
)
|
||||
}
|
||||
|
||||
const currentPageIds = paginatedQuestions.map((q) => q.id)
|
||||
const isAllCurrentPageSelected =
|
||||
currentPageIds.length > 0 && currentPageIds.every((id) => selectedIds.includes(id))
|
||||
|
||||
const toggleSelectAllCurrentPage = () => {
|
||||
setSelectedIds((prev) => {
|
||||
if (isAllCurrentPageSelected) {
|
||||
return prev.filter((id) => !currentPageIds.includes(id))
|
||||
}
|
||||
const merged = new Set([...prev, ...currentPageIds])
|
||||
return Array.from(merged)
|
||||
})
|
||||
}
|
||||
|
||||
const totalCount = filteredQuestions.length
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
|
||||
const canGoPrev = page > 1
|
||||
const canGoNext = page < totalPages
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
|
|
@ -137,12 +315,23 @@ export function QuestionsPage() {
|
|||
Create and manage your question bank
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/content/questions/add" className="w-full sm:w-auto">
|
||||
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add New Question
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={selectedIds.length === 0}
|
||||
onClick={() => handleDeleteRequest(selectedIds)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete Selected ({selectedIds.length})
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/content/questions/add" className="w-full sm:w-auto">
|
||||
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add New Question
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-soft">
|
||||
|
|
@ -165,51 +354,78 @@ export function QuestionsPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={(e) => {
|
||||
setTypeFilter(e.target.value as QuestionTypeFilter)
|
||||
}}
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="multiple-choice">Multiple Choice</option>
|
||||
<option value="short-answer">Short Answer</option>
|
||||
<option value="true-false">True/False</option>
|
||||
<option value="MCQ">Multiple Choice</option>
|
||||
<option value="TRUE_FALSE">True/False</option>
|
||||
<option value="SHORT_ANSWER">Short Answer</option>
|
||||
<option value="AUDIO">Audio</option>
|
||||
</Select>
|
||||
<Select
|
||||
value={difficultyFilter}
|
||||
onChange={(e) => {
|
||||
setDifficultyFilter(e.target.value as DifficultyFilter)
|
||||
}}
|
||||
>
|
||||
<option value="all">All Difficulties</option>
|
||||
<option value="EASY">Easy</option>
|
||||
<option value="MEDIUM">Medium</option>
|
||||
<option value="HARD">Hard</option>
|
||||
</Select>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value as StatusFilter)
|
||||
}}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="DRAFT">Draft</option>
|
||||
<option value="PUBLISHED">Published</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</Select>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value)
|
||||
setPageSize(next)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<option value="10">10 / page</option>
|
||||
<option value="20">20 / page</option>
|
||||
<option value="50">50 / page</option>
|
||||
</Select>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<Select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)}>
|
||||
<option value="all">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{difficulties.length > 0 && (
|
||||
<Select
|
||||
value={difficultyFilter}
|
||||
onChange={(e) => setDifficultyFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Difficulties</option>
|
||||
{difficulties.map((diff) => (
|
||||
<option key={diff} value={diff}>
|
||||
{diff}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
<div className="text-xs font-medium text-grayScale-400">
|
||||
Showing {filteredQuestions.length} of {questions.length} questions
|
||||
Showing {paginatedQuestions.length} of {totalCount} questions
|
||||
</div>
|
||||
|
||||
{/* Questions Table */}
|
||||
{filteredQuestions.length > 0 ? (
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center rounded-lg border border-grayScale-200 py-16">
|
||||
<p className="text-sm text-grayScale-500">Loading questions...</p>
|
||||
</div>
|
||||
) : filteredQuestions.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
|
||||
<TableHead className="w-10 py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllCurrentPageSelected}
|
||||
onChange={toggleSelectAllCurrentPage}
|
||||
aria-label="Select all questions on current page"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Question
|
||||
</TableHead>
|
||||
|
|
@ -217,10 +433,10 @@ export function QuestionsPage() {
|
|||
Type
|
||||
</TableHead>
|
||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
||||
Category
|
||||
Difficulty
|
||||
</TableHead>
|
||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
||||
Difficulty
|
||||
Status
|
||||
</TableHead>
|
||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Points
|
||||
|
|
@ -231,65 +447,80 @@ export function QuestionsPage() {
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredQuestions.map((question, index) => (
|
||||
{paginatedQuestions.map((question, index) => (
|
||||
<TableRow
|
||||
key={question.id}
|
||||
className={`transition-colors hover:bg-brand-100/30 ${
|
||||
onClick={() => openDetails(question.id)}
|
||||
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
|
||||
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/50"
|
||||
}`}
|
||||
>
|
||||
<TableCell className="py-3.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(question.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={() => toggleOne(question.id)}
|
||||
aria-label={`Select question ${question.id}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md py-3.5">
|
||||
<div className="truncate text-sm font-medium text-grayScale-600">
|
||||
{question.question}
|
||||
{question.question_text}
|
||||
</div>
|
||||
{question.type === "multiple-choice" && question.options.length > 0 && (
|
||||
{question.question_type === "MCQ" && (question.options?.length ?? 0) > 0 && (
|
||||
<div className="mt-1 truncate text-xs text-grayScale-400">
|
||||
Options: {question.options.join(", ")}
|
||||
Options: {question.options?.map((opt) => opt.option_text).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-3.5">
|
||||
<Badge className={`text-xs font-medium ${typeColors[question.type]}`}>
|
||||
{typeLabels[question.type]}
|
||||
<Badge className={`text-xs font-medium ${typeColors[question.question_type] || "bg-grayScale-100 text-grayScale-600"}`}>
|
||||
{typeLabels[question.question_type] || question.question_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
|
||||
{question.category || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="hidden py-3.5 md:table-cell">
|
||||
{question.difficulty && (
|
||||
{question.difficulty_level && (
|
||||
<Badge
|
||||
variant={
|
||||
question.difficulty === "Easy"
|
||||
question.difficulty_level === "EASY"
|
||||
? "default"
|
||||
: question.difficulty === "Medium"
|
||||
: question.difficulty_level === "MEDIUM"
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{question.difficulty}
|
||||
{question.difficulty_level}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
|
||||
{question.status || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="py-3.5 text-sm font-semibold text-grayScale-600">
|
||||
{question.points}
|
||||
{question.points ?? 0}
|
||||
</TableCell>
|
||||
<TableCell className="py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link to={`/content/questions/edit/${question.id}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-grayScale-400 hover:bg-brand-100/50 hover:text-brand-500"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-grayScale-400 hover:bg-brand-100/50 hover:text-brand-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
openEdit(question.id)
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-grayScale-400 hover:bg-red-50 hover:text-destructive"
|
||||
onClick={() => handleDelete(question.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteRequest([question.id])
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -314,8 +545,225 @@ export function QuestionsPage() {
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-grayScale-200 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canGoPrev}
|
||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canGoNext}
|
||||
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{deleteDialogOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-md rounded-xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-grayScale-900">
|
||||
Delete {pendingDeleteIds.length > 1 ? "Questions" : "Question"}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-6">
|
||||
<p className="text-sm leading-relaxed text-grayScale-600">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-grayScale-800">
|
||||
{pendingDeleteIds.length} question{pendingDeleteIds.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="bg-red-500 hover:bg-red-600" onClick={handleDeleteConfirm} disabled={deleting}>
|
||||
{deleting ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailsOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-2xl rounded-xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-grayScale-900">Question Details</h2>
|
||||
<button
|
||||
onClick={() => setDetailsOpen(false)}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4 px-6 py-6">
|
||||
{detailLoading || !detailData ? (
|
||||
<p className="text-sm text-grayScale-500">Loading details...</p>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Question</p>
|
||||
<p className="mt-1 text-sm text-grayScale-700">{detailData.question_text}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<p><span className="font-medium">Type:</span> {typeLabels[detailData.question_type] || detailData.question_type}</p>
|
||||
<p><span className="font-medium">Difficulty:</span> {detailData.difficulty_level || "—"}</p>
|
||||
<p><span className="font-medium">Points:</span> {detailData.points ?? 0}</p>
|
||||
<p><span className="font-medium">Status:</span> {detailData.status || "—"}</p>
|
||||
</div>
|
||||
{(detailData.options ?? []).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Options</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{(detailData.options ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.option_order - b.option_order)
|
||||
.map((opt) => (
|
||||
<div
|
||||
key={`${opt.option_order}-${opt.option_text}`}
|
||||
className={`rounded-md border px-3 py-2 text-sm ${
|
||||
opt.is_correct ? "border-green-200 bg-green-50 text-green-700" : "border-grayScale-200 bg-grayScale-50 text-grayScale-600"
|
||||
}`}
|
||||
>
|
||||
{opt.option_order}. {opt.option_text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="mx-4 max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-grayScale-900">Edit Question</h2>
|
||||
<button
|
||||
onClick={() => setEditOpen(false)}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4 px-6 py-6">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-grayScale-600">Question Text</label>
|
||||
<Textarea value={editQuestionText} onChange={(e) => setEditQuestionText(e.target.value)} rows={3} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
|
||||
<Select value={editQuestionType} onChange={(e) => setEditQuestionType(e.target.value as QuestionTypeEdit)}>
|
||||
<option value="MCQ">Multiple Choice</option>
|
||||
<option value="TRUE_FALSE">True/False</option>
|
||||
<option value="SHORT_ANSWER">Short Answer</option>
|
||||
<option value="AUDIO">Audio</option>
|
||||
</Select>
|
||||
<Select value={editDifficulty} onChange={(e) => setEditDifficulty(e.target.value)}>
|
||||
<option value="EASY">Easy</option>
|
||||
<option value="MEDIUM">Medium</option>
|
||||
<option value="HARD">Hard</option>
|
||||
</Select>
|
||||
<Input type="number" min={1} value={editPoints} onChange={(e) => setEditPoints(Number(e.target.value) || 1)} />
|
||||
<Select value={editStatus} onChange={(e) => setEditStatus(e.target.value)}>
|
||||
<option value="DRAFT">Draft</option>
|
||||
<option value="PUBLISHED">Published</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</Select>
|
||||
</div>
|
||||
{editQuestionType !== "SHORT_ANSWER" && editQuestionType !== "AUDIO" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-600">Options</label>
|
||||
{editOptions.map((opt, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={opt.is_correct}
|
||||
onChange={() =>
|
||||
setEditOptions((prev) =>
|
||||
prev.map((item, i) => ({ ...item, is_correct: i === idx })),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
value={opt.option_text}
|
||||
onChange={(e) =>
|
||||
setEditOptions((prev) =>
|
||||
prev.map((item, i) =>
|
||||
i === idx ? { ...item, option_text: e.target.value } : item,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder={`Option ${idx + 1}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setEditOptions((prev) => [
|
||||
...prev,
|
||||
{ option_text: "", option_order: prev.length + 1, is_correct: false },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Option
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{editQuestionType === "SHORT_ANSWER" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-grayScale-600">Short Answer</label>
|
||||
<Input value={editShortAnswer} onChange={(e) => setEditShortAnswer(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Input value={editTips} onChange={(e) => setEditTips(e.target.value)} placeholder="Tips (optional)" />
|
||||
<Input value={editExplanation} onChange={(e) => setEditExplanation(e.target.value)} placeholder="Explanation (optional)" />
|
||||
<Input value={editVoicePrompt} onChange={(e) => setEditVoicePrompt(e.target.value)} placeholder="Voice prompt (optional)" />
|
||||
<Input value={editSampleAnswerVoicePrompt} onChange={(e) => setEditSampleAnswerVoicePrompt(e.target.value)} placeholder="Sample answer voice prompt (optional)" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={savingEdit}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="bg-brand-500 hover:bg-brand-600" onClick={saveEdit} disabled={savingEdit}>
|
||||
{savingEdit ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,164 @@
|
|||
import { Link } from "react-router-dom"
|
||||
import { Plus, Mic } from "lucide-react"
|
||||
import { Card, CardContent } from "../../components/ui/card"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Plus, Mic, X } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Textarea } from "../../components/ui/textarea"
|
||||
import { Select } from "../../components/ui/select"
|
||||
import {
|
||||
addQuestionToSet,
|
||||
createQuestion,
|
||||
createQuestionSet,
|
||||
getQuestions,
|
||||
} from "../../api/courses.api"
|
||||
import type { QuestionDetail } from "../../types/course.types"
|
||||
|
||||
export function SpeakingPage() {
|
||||
const [audioQuestions, setAudioQuestions] = useState<QuestionDetail[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [openCreate, setOpenCreate] = useState(false)
|
||||
|
||||
const [setTitle, setSetTitle] = useState("")
|
||||
const [setDescription, setSetDescription] = useState("")
|
||||
const [ownerType, setOwnerType] = useState<"SUB_COURSE" | "COURSE">("SUB_COURSE")
|
||||
const [ownerId, setOwnerId] = useState("")
|
||||
const [setStatus, setSetStatus] = useState<"DRAFT" | "PUBLISHED">("PUBLISHED")
|
||||
|
||||
const [questionText, setQuestionText] = useState("")
|
||||
const [difficulty, setDifficulty] = useState("EASY")
|
||||
const [points, setPoints] = useState(1)
|
||||
const [voicePrompt, setVoicePrompt] = useState("")
|
||||
const [sampleAnswerVoicePrompt, setSampleAnswerVoicePrompt] = useState("")
|
||||
const [audioCorrectAnswerText, setAudioCorrectAnswerText] = useState("")
|
||||
|
||||
const fetchAudioQuestions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const batchSize = 100
|
||||
let nextOffset = 0
|
||||
let expectedTotal = Number.POSITIVE_INFINITY
|
||||
let allRows: QuestionDetail[] = []
|
||||
|
||||
while (allRows.length < expectedTotal) {
|
||||
const res = await getQuestions({
|
||||
question_type: "AUDIO",
|
||||
limit: batchSize,
|
||||
offset: nextOffset,
|
||||
})
|
||||
const payload = res.data?.data as unknown
|
||||
const meta = res.data?.metadata as { total_count?: number } | null | undefined
|
||||
|
||||
let chunk: QuestionDetail[] = []
|
||||
let chunkTotal: number | undefined
|
||||
if (Array.isArray(payload)) {
|
||||
chunk = payload as QuestionDetail[]
|
||||
chunkTotal = meta?.total_count
|
||||
} else if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
Array.isArray((payload as { questions?: unknown[] }).questions)
|
||||
) {
|
||||
const data = payload as { questions: QuestionDetail[]; total_count?: number }
|
||||
chunk = data.questions
|
||||
chunkTotal = data.total_count ?? meta?.total_count
|
||||
}
|
||||
|
||||
allRows = [...allRows, ...chunk]
|
||||
if (typeof chunkTotal === "number" && Number.isFinite(chunkTotal)) {
|
||||
expectedTotal = chunkTotal
|
||||
}
|
||||
|
||||
if (chunk.length < batchSize) break
|
||||
nextOffset += chunk.length
|
||||
}
|
||||
|
||||
setAudioQuestions(allRows)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch audio questions:", error)
|
||||
setAudioQuestions([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAudioQuestions()
|
||||
}, [fetchAudioQuestions])
|
||||
|
||||
const resetCreateForm = () => {
|
||||
setSetTitle("")
|
||||
setSetDescription("")
|
||||
setOwnerType("SUB_COURSE")
|
||||
setOwnerId("")
|
||||
setSetStatus("PUBLISHED")
|
||||
setQuestionText("")
|
||||
setDifficulty("EASY")
|
||||
setPoints(1)
|
||||
setVoicePrompt("")
|
||||
setSampleAnswerVoicePrompt("")
|
||||
setAudioCorrectAnswerText("")
|
||||
}
|
||||
|
||||
const canCreate = useMemo(() => {
|
||||
return (
|
||||
setTitle.trim().length > 0 &&
|
||||
ownerId.trim().length > 0 &&
|
||||
questionText.trim().length > 0 &&
|
||||
voicePrompt.trim().length > 0 &&
|
||||
sampleAnswerVoicePrompt.trim().length > 0 &&
|
||||
audioCorrectAnswerText.trim().length > 0
|
||||
)
|
||||
}, [setTitle, ownerId, questionText, voicePrompt, sampleAnswerVoicePrompt, audioCorrectAnswerText])
|
||||
|
||||
const handleCreateSpeakingPractice = async () => {
|
||||
if (!canCreate) return
|
||||
const parsedOwnerId = Number(ownerId)
|
||||
if (!Number.isFinite(parsedOwnerId) || parsedOwnerId <= 0) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const setRes = await createQuestionSet({
|
||||
title: setTitle.trim(),
|
||||
description: setDescription.trim(),
|
||||
set_type: "PRACTICE",
|
||||
owner_type: ownerType,
|
||||
owner_id: parsedOwnerId,
|
||||
status: setStatus,
|
||||
})
|
||||
|
||||
const setId = setRes.data?.data?.id
|
||||
if (!setId) throw new Error("Question set creation failed: missing set ID")
|
||||
|
||||
const questionRes = await createQuestion({
|
||||
question_text: questionText.trim(),
|
||||
question_type: "AUDIO",
|
||||
status: "PUBLISHED",
|
||||
difficulty_level: difficulty,
|
||||
points,
|
||||
voice_prompt: voicePrompt.trim(),
|
||||
sample_answer_voice_prompt: sampleAnswerVoicePrompt.trim(),
|
||||
audio_correct_answer_text: audioCorrectAnswerText.trim(),
|
||||
})
|
||||
|
||||
const questionId = questionRes.data?.data?.id
|
||||
if (!questionId) throw new Error("Question creation failed: missing question ID")
|
||||
|
||||
await addQuestionToSet(setId, {
|
||||
question_id: questionId,
|
||||
display_order: 1,
|
||||
})
|
||||
|
||||
setOpenCreate(false)
|
||||
resetCreateForm()
|
||||
await fetchAudioQuestions()
|
||||
} catch (error) {
|
||||
console.error("Failed to create speaking practice:", error)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
|
@ -15,35 +170,157 @@ export function SpeakingPage() {
|
|||
Create and manage speaking practice sessions for your learners.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/content/speaking/add-practice" className="w-full sm:w-auto">
|
||||
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add New Practice
|
||||
</Button>
|
||||
</Link>
|
||||
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto" onClick={() => setOpenCreate(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add New Speaking Practice
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="border-2 border-dashed border-grayScale-200 shadow-none">
|
||||
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="mb-6 grid h-20 w-20 place-items-center rounded-full bg-gradient-to-br from-brand-100 to-brand-200">
|
||||
<Mic className="h-10 w-10 text-brand-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-grayScale-600">
|
||||
No speaking practices yet
|
||||
</h3>
|
||||
<p className="mt-2 max-w-md text-sm leading-relaxed text-grayScale-400">
|
||||
Get started by adding your first speaking practice session. Your
|
||||
learners will be able to practice pronunciation and conversation
|
||||
skills.
|
||||
</p>
|
||||
<Link to="/content/speaking/add-practice" className="mt-8">
|
||||
<Button className="bg-brand-500 px-6 hover:bg-brand-600">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Your First Practice
|
||||
</Button>
|
||||
</Link>
|
||||
<Card className="shadow-soft">
|
||||
<CardHeader className="border-b border-grayScale-200 pb-4">
|
||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||
AUDIO Questions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-5">
|
||||
{loading ? (
|
||||
<div className="py-14 text-center text-sm text-grayScale-500">Loading audio questions...</div>
|
||||
) : audioQuestions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-14 text-center">
|
||||
<div className="mb-6 grid h-16 w-16 place-items-center rounded-full bg-gradient-to-br from-brand-100 to-brand-200">
|
||||
<Mic className="h-8 w-8 text-brand-500" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-grayScale-600">No audio questions yet</h3>
|
||||
<p className="mt-2 max-w-md text-sm leading-relaxed text-grayScale-400">
|
||||
Create a speaking practice to automatically create and attach an AUDIO question.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{audioQuestions.map((question, idx) => (
|
||||
<div
|
||||
key={question.id}
|
||||
className={`rounded-lg border px-4 py-3 ${
|
||||
idx % 2 === 0 ? "border-grayScale-200 bg-white" : "border-grayScale-100 bg-grayScale-50"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-grayScale-700">{question.question_text}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-md bg-purple-100 px-2 py-1 text-purple-700">AUDIO</span>
|
||||
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
|
||||
Difficulty: {question.difficulty_level || "—"}
|
||||
</span>
|
||||
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
|
||||
Points: {question.points ?? 0}
|
||||
</span>
|
||||
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
|
||||
Status: {question.status || "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{openCreate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="mx-4 max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-grayScale-900">Create Speaking Practice</h2>
|
||||
<button
|
||||
onClick={() => setOpenCreate(false)}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-5 px-6 py-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<label className="text-sm font-medium text-grayScale-600">Practice Title</label>
|
||||
<Input value={setTitle} onChange={(e) => setSetTitle(e.target.value)} placeholder="Speaking practice title" />
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<label className="text-sm font-medium text-grayScale-600">Practice Description (Optional)</label>
|
||||
<Textarea value={setDescription} onChange={(e) => setSetDescription(e.target.value)} rows={2} placeholder="Brief description" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-grayScale-600">Owner Type</label>
|
||||
<Select value={ownerType} onChange={(e) => setOwnerType(e.target.value as "SUB_COURSE" | "COURSE")}>
|
||||
<option value="SUB_COURSE">SUB_COURSE</option>
|
||||
<option value="COURSE">COURSE</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-grayScale-600">Owner ID</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={ownerId}
|
||||
onChange={(e) => setOwnerId(e.target.value)}
|
||||
placeholder="e.g. 12"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-grayScale-600">Set Status</label>
|
||||
<Select value={setStatus} onChange={(e) => setSetStatus(e.target.value as "DRAFT" | "PUBLISHED")}>
|
||||
<option value="PUBLISHED">PUBLISHED</option>
|
||||
<option value="DRAFT">DRAFT</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-4">
|
||||
<p className="mb-3 text-sm font-semibold text-grayScale-700">AUDIO Question</p>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<label className="text-sm font-medium text-grayScale-600">Question Text</label>
|
||||
<Textarea value={questionText} onChange={(e) => setQuestionText(e.target.value)} rows={2} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-grayScale-600">Difficulty</label>
|
||||
<Select value={difficulty} onChange={(e) => setDifficulty(e.target.value)}>
|
||||
<option value="EASY">EASY</option>
|
||||
<option value="MEDIUM">MEDIUM</option>
|
||||
<option value="HARD">HARD</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-grayScale-600">Points</label>
|
||||
<Input type="number" min={1} value={points} onChange={(e) => setPoints(Number(e.target.value) || 1)} />
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<label className="text-sm font-medium text-grayScale-600">Voice Prompt</label>
|
||||
<Textarea value={voicePrompt} onChange={(e) => setVoicePrompt(e.target.value)} rows={2} />
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<label className="text-sm font-medium text-grayScale-600">Sample Answer Voice Prompt</label>
|
||||
<Textarea value={sampleAnswerVoicePrompt} onChange={(e) => setSampleAnswerVoicePrompt(e.target.value)} rows={2} />
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<label className="text-sm font-medium text-grayScale-600">Audio Correct Answer Text</label>
|
||||
<Textarea value={audioCorrectAnswerText} onChange={(e) => setAudioCorrectAnswerText(e.target.value)} rows={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={() => setOpenCreate(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-brand-500 hover:bg-brand-600"
|
||||
disabled={!canCreate || saving}
|
||||
onClick={handleCreateSpeakingPractice}
|
||||
>
|
||||
{saving ? "Creating..." : "Create Practice + Audio Question"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ import { cn } from "../../lib/utils";
|
|||
import { useUsersStore } from "../../zustand/userStore";
|
||||
import { getUserById } from "../../api/users.api";
|
||||
import { getCourseCategories, getCoursesByCategory } from "../../api/courses.api";
|
||||
import { getAdminLearnerCourseProgress } from "../../api/progress.api";
|
||||
import {
|
||||
getAdminLearnerCourseProgress,
|
||||
getAdminLearnerCourseProgressSummary,
|
||||
} from "../../api/progress.api";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -36,7 +39,7 @@ import {
|
|||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import type { LearnerCourseProgressItem } from "../../types/progress.types";
|
||||
import type { LearnerCourseProgressItem, LearnerCourseProgressSummary } from "../../types/progress.types";
|
||||
import type { Course } from "../../types/course.types";
|
||||
|
||||
const activityIcons: Record<string, typeof CheckCircle2> = {
|
||||
|
|
@ -55,6 +58,7 @@ export function UserDetailPage() {
|
|||
const [loadingCourseOptions, setLoadingCourseOptions] = useState(false);
|
||||
const [selectedProgressCourseId, setSelectedProgressCourseId] = useState<number | null>(null);
|
||||
const [progressItems, setProgressItems] = useState<LearnerCourseProgressItem[]>([]);
|
||||
const [progressSummary, setProgressSummary] = useState<LearnerCourseProgressSummary | null>(null);
|
||||
const [loadingProgress, setLoadingProgress] = useState(false);
|
||||
const [progressError, setProgressError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -115,12 +119,18 @@ export function UserDetailPage() {
|
|||
setLoadingProgress(true);
|
||||
setProgressError(null);
|
||||
try {
|
||||
const res = await getAdminLearnerCourseProgress(userId, selectedProgressCourseId);
|
||||
const ordered = [...(res.data?.data ?? [])].sort(
|
||||
const [summaryRes, detailRes] = await Promise.all([
|
||||
getAdminLearnerCourseProgressSummary(userId, selectedProgressCourseId),
|
||||
getAdminLearnerCourseProgress(userId, selectedProgressCourseId),
|
||||
]);
|
||||
|
||||
setProgressSummary(summaryRes.data?.data ?? null);
|
||||
const ordered = [...(detailRes.data?.data ?? [])].sort(
|
||||
(a, b) => a.display_order - b.display_order || a.sub_course_id - b.sub_course_id,
|
||||
);
|
||||
setProgressItems(ordered);
|
||||
} catch (err: any) {
|
||||
setProgressSummary(null);
|
||||
setProgressItems([]);
|
||||
const status = err?.response?.status;
|
||||
if (status === 403) {
|
||||
|
|
@ -139,6 +149,16 @@ export function UserDetailPage() {
|
|||
}, [id, selectedProgressCourseId]);
|
||||
|
||||
const progressMetrics = useMemo(() => {
|
||||
if (progressSummary) {
|
||||
return {
|
||||
total: progressSummary.total_sub_courses ?? 0,
|
||||
completed: progressSummary.completed_sub_courses ?? 0,
|
||||
inProgress: progressSummary.in_progress_sub_courses ?? 0,
|
||||
locked: progressSummary.locked_sub_courses ?? 0,
|
||||
averageProgress: Math.round(progressSummary.overall_progress_percentage ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
const total = progressItems.length;
|
||||
const completed = progressItems.filter((item) => item.progress_status === "COMPLETED").length;
|
||||
const inProgress = progressItems.filter((item) => item.progress_status === "IN_PROGRESS").length;
|
||||
|
|
@ -151,7 +171,7 @@ export function UserDetailPage() {
|
|||
);
|
||||
|
||||
return { total, completed, inProgress, locked, averageProgress };
|
||||
}, [progressItems]);
|
||||
}, [progressItems, progressSummary]);
|
||||
|
||||
if (!userProfile) {
|
||||
return (
|
||||
|
|
@ -214,7 +234,7 @@ export function UserDetailPage() {
|
|||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
to="/users"
|
||||
to="/users/list"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-grayScale-500 transition-colors hover:text-brand-600"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
|
@ -440,7 +460,7 @@ export function UserDetailPage() {
|
|||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<Metric label="Total Courses" value={progressMetrics.total} />
|
||||
<Metric label="Total Sub-courses" value={progressMetrics.total} />
|
||||
<Metric label="Completed" value={progressMetrics.completed} />
|
||||
<Metric label="In Progress" value={progressMetrics.inProgress} />
|
||||
<Metric label="Locked" value={progressMetrics.locked} />
|
||||
|
|
|
|||
|
|
@ -229,6 +229,7 @@ export function UsersListPage() {
|
|||
/>
|
||||
</TableHead>
|
||||
<TableHead>USER</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Role</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Phone</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Country</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Region</TableHead>
|
||||
|
|
@ -239,7 +240,7 @@ export function UsersListPage() {
|
|||
<TableBody>
|
||||
{users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-16 text-center">
|
||||
<TableCell colSpan={7} className="py-16 text-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-grayScale-100">
|
||||
<Users className="h-7 w-7 text-grayScale-400" />
|
||||
|
|
@ -283,6 +284,7 @@ export function UsersListPage() {
|
|||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-grayScale-500">{u.role || "-"}</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-grayScale-500">{u.phoneNumber || "-"}</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-grayScale-500">{u.country || "-"}</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-grayScale-500">{u.region || "-"}</TableCell>
|
||||
|
|
|
|||
|
|
@ -318,6 +318,8 @@ export interface PracticeQuestion {
|
|||
id: number
|
||||
practice_id: number
|
||||
question: string
|
||||
points?: number
|
||||
difficulty_level?: string
|
||||
question_voice_prompt: string
|
||||
sample_answer_voice_prompt: string
|
||||
sample_answer: string
|
||||
|
|
@ -343,6 +345,11 @@ export interface CreatePracticeQuestionRequest {
|
|||
sample_answer_voice_prompt?: string
|
||||
sample_answer: string
|
||||
tips?: string
|
||||
explanation?: string
|
||||
difficulty_level?: string
|
||||
points?: number
|
||||
options?: QuestionOption[]
|
||||
short_answers?: string[]
|
||||
type: "MCQ" | "TRUE_FALSE" | "SHORT"
|
||||
}
|
||||
|
||||
|
|
@ -352,6 +359,11 @@ export interface UpdatePracticeQuestionRequest {
|
|||
sample_answer_voice_prompt?: string
|
||||
sample_answer: string
|
||||
tips?: string
|
||||
explanation?: string
|
||||
difficulty_level?: string
|
||||
points?: number
|
||||
options?: QuestionOption[]
|
||||
short_answers?: string[]
|
||||
type: "MCQ" | "TRUE_FALSE" | "SHORT"
|
||||
}
|
||||
|
||||
|
|
@ -375,7 +387,65 @@ export interface QuestionSet {
|
|||
|
||||
export interface GetQuestionSetsResponse {
|
||||
message: string
|
||||
data: QuestionSet[]
|
||||
data: QuestionSet[] | { question_sets: QuestionSet[]; total_count?: number }
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface GetQuestionSetsParams {
|
||||
set_type?: "PRACTICE" | "INITIAL_ASSESSMENT" | "EXAM" | string
|
||||
owner_type?: "SUB_COURSE" | "COURSE" | string
|
||||
owner_id?: number
|
||||
status?: "DRAFT" | "PUBLISHED" | "ARCHIVED" | string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface QuestionSetDetail {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
set_type: string
|
||||
owner_type: string
|
||||
owner_id: number
|
||||
banner_image?: string | null
|
||||
persona?: string | null
|
||||
time_limit_minutes?: number | null
|
||||
passing_score?: number | null
|
||||
shuffle_questions?: boolean
|
||||
status: string
|
||||
sub_course_video_id?: number | null
|
||||
created_at: string
|
||||
question_count: number
|
||||
}
|
||||
|
||||
export interface GetQuestionSetDetailResponse {
|
||||
message: string
|
||||
data: QuestionSetDetail
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface QuestionSetQuestion {
|
||||
id: number
|
||||
set_id: number
|
||||
question_id: number
|
||||
display_order: number
|
||||
question_text: string
|
||||
question_type: "MCQ" | "TRUE_FALSE" | "SHORT" | string
|
||||
difficulty_level?: string | null
|
||||
points?: number
|
||||
explanation?: string | null
|
||||
tips?: string | null
|
||||
voice_prompt?: string | null
|
||||
question_status?: string
|
||||
}
|
||||
|
||||
export interface GetQuestionSetQuestionsResponse {
|
||||
message: string
|
||||
data: QuestionSetQuestion[]
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
|
|
@ -396,7 +466,7 @@ export interface CreateQuestionSetRequest {
|
|||
}
|
||||
|
||||
export interface AddQuestionToSetRequest {
|
||||
display_order: number
|
||||
display_order?: number
|
||||
question_id: number
|
||||
}
|
||||
|
||||
|
|
@ -409,15 +479,16 @@ export interface QuestionOption {
|
|||
export interface CreateQuestionRequest {
|
||||
question_text: string
|
||||
question_type: string
|
||||
difficulty_level: string
|
||||
points: number
|
||||
difficulty_level?: string
|
||||
points?: number
|
||||
tips?: string
|
||||
explanation?: string
|
||||
status?: string
|
||||
options?: QuestionOption[]
|
||||
voice_prompt?: string
|
||||
sample_answer_voice_prompt?: string
|
||||
short_answers?: string[]
|
||||
audio_correct_answer_text?: string
|
||||
short_answers?: string[] | { acceptable_answer: string; match_type: "EXACT" | "CASE_INSENSITIVE" }[]
|
||||
}
|
||||
|
||||
export interface CreateQuestionResponse {
|
||||
|
|
@ -430,6 +501,52 @@ export interface CreateQuestionResponse {
|
|||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface QuestionShortAnswer {
|
||||
acceptable_answer: string
|
||||
match_type?: "EXACT" | "CASE_INSENSITIVE" | string
|
||||
}
|
||||
|
||||
export interface QuestionDetail {
|
||||
id: number
|
||||
question_text: string
|
||||
question_type: "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "SHORT" | string
|
||||
difficulty_level?: string | null
|
||||
points?: number | null
|
||||
status?: string
|
||||
created_at?: string
|
||||
options?: ({ id?: number } & QuestionOption)[]
|
||||
short_answers?: string[] | QuestionShortAnswer[]
|
||||
tips?: string | null
|
||||
explanation?: string | null
|
||||
voice_prompt?: string | null
|
||||
sample_answer_voice_prompt?: string | null
|
||||
audio_correct_answer_text?: string | null
|
||||
}
|
||||
|
||||
export interface GetQuestionDetailResponse {
|
||||
message: string
|
||||
data: QuestionDetail
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface GetQuestionsParams {
|
||||
question_type?: "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" | string
|
||||
difficulty?: "EASY" | "MEDIUM" | "HARD" | string
|
||||
status?: "DRAFT" | "PUBLISHED" | "INACTIVE" | string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface GetQuestionsResponse {
|
||||
message: string
|
||||
data: QuestionDetail[] | { questions: QuestionDetail[]; total_count?: number }
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface CreateQuestionSetResponse {
|
||||
message: string
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -18,3 +18,19 @@ export interface LearnerCourseProgressResponse {
|
|||
message: string
|
||||
data: LearnerCourseProgressItem[]
|
||||
}
|
||||
|
||||
export interface LearnerCourseProgressSummary {
|
||||
course_id: number
|
||||
learner_user_id: number
|
||||
overall_progress_percentage: number
|
||||
total_sub_courses: number
|
||||
completed_sub_courses: number
|
||||
in_progress_sub_courses: number
|
||||
not_started_sub_courses: number
|
||||
locked_sub_courses: number
|
||||
}
|
||||
|
||||
export interface LearnerCourseProgressSummaryResponse {
|
||||
message: string
|
||||
data: LearnerCourseProgressSummary
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export interface User {
|
|||
nickName: string
|
||||
email: string
|
||||
phoneNumber: string
|
||||
role: string
|
||||
region: string
|
||||
country: string
|
||||
lastLogin: string | null
|
||||
|
|
@ -63,6 +64,7 @@ export const mapUserApiToUser = (u: UserApiDTO): User => ({
|
|||
nickName: u.nick_name,
|
||||
email: u.email,
|
||||
phoneNumber: u.phone_number ?? "",
|
||||
role: u.role,
|
||||
region: u.region,
|
||||
country: u.country,
|
||||
lastLogin: null,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user