diff --git a/src/pages/content-management/HumanLanguagePage.tsx b/src/pages/content-management/HumanLanguagePage.tsx index 196f70b..96a0f38 100644 --- a/src/pages/content-management/HumanLanguagePage.tsx +++ b/src/pages/content-management/HumanLanguagePage.tsx @@ -28,20 +28,30 @@ import { } from "../../components/ui/dialog" import { SpinnerIcon } from "../../components/ui/spinner-icon" import { + addQuestionToSet, + createPractice, + createQuestion, createCourse, createCourseCategory, createHumanLanguageLesson, + deletePractice, + deleteQuestion, deleteSubCourse, getHumanLanguageHierarchy, + getQuestionById, getPracticeQuestions, getPracticeQuestionsByPractice, + updatePractice, + updateQuestion, } from "../../api/courses.api" import { Badge } from "../../components/ui/badge" import type { + CreateQuestionRequest, HumanLanguageCourseTree, HumanLanguageSubCategoryTree, LearningPathPractice, LearningPathVideo, + QuestionDetail, QuestionSetQuestion, } from "../../types/course.types" import { cn } from "../../lib/utils" @@ -58,6 +68,24 @@ type PracticeQuestionsFetchState = | { status: "ok"; questions: QuestionSetQuestion[]; totalCount: number } | { status: "error"; message: string } +type PracticeDialogState = + | { open: false } + | { + open: true + mode: "create" | "edit" + subModuleId: number + practiceId?: number + } + +type QuestionDialogState = + | { open: false } + | { + open: true + mode: "create" | "edit" + practiceId: number + questionId?: number + } + function formatDurationSeconds(total: number): string { const s = Math.max(0, Math.floor(total)) const m = Math.floor(s / 60) @@ -171,6 +199,39 @@ export function HumanLanguagePage() { /** Selected lesson / practice card per sub-module (for inline detail panel). */ const [subModuleCardSelection, setSubModuleCardSelection] = useState>({}) const [practiceQuestionsState, setPracticeQuestionsState] = useState>({}) + const [practiceDialog, setPracticeDialog] = useState({ open: false }) + const [questionDialog, setQuestionDialog] = useState({ open: false }) + const [practiceForm, setPracticeForm] = useState({ title: "", description: "", persona: "" }) + const [questionForm, setQuestionForm] = useState({ + questionText: "", + questionType: "MCQ" as "MCQ" | "TRUE_FALSE" | "SHORT", + difficulty: "EASY", + points: 1, + tips: "", + explanation: "", + imageUrl: "", + voicePrompt: "", + sampleAnswerVoicePrompt: "", + audioCorrectAnswerText: "", + optionA: "", + optionB: "", + optionC: "", + optionD: "", + correctOption: "A" as "A" | "B" | "C" | "D", + shortAnswer: "", + }) + const [questionDetailById, setQuestionDetailById] = useState>({}) + const [practiceTargetDelete, setPracticeTargetDelete] = useState<{ id: number; title: string } | null>(null) + const [questionTargetDelete, setQuestionTargetDelete] = useState<{ id: number; practiceId: number; text: string } | null>(null) + const [savingPractice, setSavingPractice] = useState(false) + const [savingQuestion, setSavingQuestion] = useState(false) + const [deletingPractice, setDeletingPractice] = useState(false) + const [deletingQuestion, setDeletingQuestion] = useState(false) + /** Show inline field errors only after an explicit save attempt (avoids noisy empty-state on open). */ + const [practiceSubmitAttempted, setPracticeSubmitAttempted] = useState(false) + const [questionSubmitAttempted, setQuestionSubmitAttempted] = useState(false) + const [practiceFormTouched, setPracticeFormTouched] = useState(false) + const [questionFormTouched, setQuestionFormTouched] = useState(false) const renderMediaPreview = ( urlRaw: string, @@ -495,15 +556,15 @@ export function HumanLanguagePage() { } } - const loadPracticeQuestionsIfNeeded = async (practiceId: number) => { + const loadPracticeQuestionsIfNeeded = async (practiceId: number, forceRefresh = false) => { let skipFetch = false setPracticeQuestionsState((prev) => { const ex = prev[practiceId] - if (ex?.status === "ok") { + if (!forceRefresh && ex?.status === "ok") { skipFetch = true return prev } - if (ex?.status === "loading" && Date.now() - ex.startedAt < 15000) { + if (!forceRefresh && ex?.status === "loading" && Date.now() - ex.startedAt < 15000) { skipFetch = true return prev } @@ -540,6 +601,281 @@ export function HumanLanguagePage() { const getSubModuleSelection = (smKey: string): SubModuleCardSelection => subModuleCardSelection[smKey] ?? { lessonId: null, practiceId: null } + const resetPracticeForm = () => setPracticeForm({ title: "", description: "", persona: "" }) + const resetQuestionForm = () => + setQuestionForm({ + questionText: "", + questionType: "MCQ", + difficulty: "EASY", + points: 1, + tips: "", + explanation: "", + imageUrl: "", + voicePrompt: "", + sampleAnswerVoicePrompt: "", + audioCorrectAnswerText: "", + optionA: "", + optionB: "", + optionC: "", + optionD: "", + correctOption: "A", + shortAnswer: "", + }) + + const openCreatePracticeDialog = (subModuleId: number) => { + setPracticeSubmitAttempted(false) + setPracticeFormTouched(false) + resetPracticeForm() + setPracticeDialog({ open: true, mode: "create", subModuleId }) + } + + const openEditPracticeDialog = (subModuleId: number, p: LearningPathPractice) => { + setPracticeSubmitAttempted(false) + setPracticeFormTouched(false) + setPracticeForm({ title: p.title ?? "", description: "", persona: "" }) + setPracticeDialog({ open: true, mode: "edit", subModuleId, practiceId: p.id }) + } + + const practiceFieldErrors = useMemo(() => { + const title = practiceForm.title.trim() + return { + title: title ? undefined : "Title is required.", + } + }, [practiceForm.title]) + + const practiceCanSave = !practiceFieldErrors.title + + const handleSavePractice = async () => { + if (!practiceDialog.open) return + if (!practiceCanSave) { + setPracticeSubmitAttempted(true) + return + } + setSavingPractice(true) + try { + if (practiceDialog.mode === "create") { + await createPractice({ + sub_course_id: practiceDialog.subModuleId, + title: practiceForm.title.trim(), + description: practiceForm.description.trim(), + persona: practiceForm.persona.trim() || undefined, + }) + toast.success("Practice created") + } else if (practiceDialog.practiceId) { + await updatePractice(practiceDialog.practiceId, { + title: practiceForm.title.trim(), + description: practiceForm.description.trim(), + persona: practiceForm.persona.trim() || undefined, + }) + toast.success("Practice updated") + } + setPracticeDialog({ open: false }) + setPracticeSubmitAttempted(false) + setPracticeFormTouched(false) + resetPracticeForm() + await loadHierarchy() + } catch (error) { + console.error("Failed to save practice:", error) + toast.error("Failed to save practice") + } finally { + setSavingPractice(false) + } + } + + const openCreateQuestionDialog = (practiceId: number) => { + setQuestionSubmitAttempted(false) + setQuestionFormTouched(false) + resetQuestionForm() + setQuestionDialog({ open: true, mode: "create", practiceId }) + } + + const openEditQuestionDialog = async (practiceId: number, question: QuestionSetQuestion) => { + setQuestionSubmitAttempted(false) + setQuestionFormTouched(false) + const qid = question.question_id ?? question.id + resetQuestionForm() + setQuestionDialog({ open: true, mode: "edit", practiceId, questionId: qid }) + try { + const detail = questionDetailById[qid] ?? (await getQuestionById(qid)).data?.data + if (!detail) return + setQuestionDetailById((prev) => ({ ...prev, [qid]: detail })) + const options = (detail.options ?? []).slice().sort((a, b) => a.option_order - b.option_order) + const correct = options.find((o) => o.is_correct)?.option_order ?? 1 + const shortAnswer = + Array.isArray(detail.short_answers) && detail.short_answers.length > 0 + ? typeof detail.short_answers[0] === "string" + ? detail.short_answers[0] + : detail.short_answers[0]?.acceptable_answer ?? "" + : "" + setQuestionForm({ + questionText: detail.question_text ?? "", + questionType: + detail.question_type === "TRUE_FALSE" || detail.question_type === "SHORT" || detail.question_type === "SHORT_ANSWER" + ? detail.question_type === "SHORT_ANSWER" + ? "SHORT" + : detail.question_type + : "MCQ", + difficulty: detail.difficulty_level ?? "EASY", + points: detail.points ?? 1, + tips: detail.tips ?? "", + explanation: detail.explanation ?? "", + imageUrl: detail.image_url ?? "", + voicePrompt: detail.voice_prompt ?? "", + sampleAnswerVoicePrompt: detail.sample_answer_voice_prompt ?? "", + audioCorrectAnswerText: detail.audio_correct_answer_text ?? "", + optionA: options[0]?.option_text ?? "", + optionB: options[1]?.option_text ?? "", + optionC: options[2]?.option_text ?? "", + optionD: options[3]?.option_text ?? "", + correctOption: (["A", "B", "C", "D"][Math.min(Math.max(correct - 1, 0), 3)] as "A" | "B" | "C" | "D") ?? "A", + shortAnswer, + }) + } catch (error) { + console.error("Failed to load question detail:", error) + toast.error("Could not load question details") + } + } + + const buildQuestionPayload = (): CreateQuestionRequest => { + const payload: CreateQuestionRequest = { + question_text: questionForm.questionText.trim(), + question_type: questionForm.questionType, + difficulty_level: questionForm.difficulty, + points: Number(questionForm.points) || 1, + tips: questionForm.tips.trim() || undefined, + explanation: questionForm.explanation.trim() || undefined, + image_url: questionForm.imageUrl.trim() || undefined, + voice_prompt: questionForm.voicePrompt.trim() || undefined, + sample_answer_voice_prompt: questionForm.sampleAnswerVoicePrompt.trim() || undefined, + audio_correct_answer_text: questionForm.audioCorrectAnswerText.trim() || undefined, + status: "PUBLISHED", + } + if (questionForm.questionType === "SHORT") { + payload.short_answers = questionForm.shortAnswer.trim() + ? [ + { acceptable_answer: questionForm.shortAnswer.trim(), match_type: "EXACT" }, + { acceptable_answer: questionForm.shortAnswer.trim(), match_type: "CASE_INSENSITIVE" }, + ] + : undefined + return payload + } + const options = + questionForm.questionType === "TRUE_FALSE" + ? [ + { option_order: 1, option_text: "True", is_correct: questionForm.correctOption === "A" }, + { option_order: 2, option_text: "False", is_correct: questionForm.correctOption === "B" }, + ] + : [ + { option_order: 1, option_text: questionForm.optionA.trim(), is_correct: questionForm.correctOption === "A" }, + { option_order: 2, option_text: questionForm.optionB.trim(), is_correct: questionForm.correctOption === "B" }, + { option_order: 3, option_text: questionForm.optionC.trim(), is_correct: questionForm.correctOption === "C" }, + { option_order: 4, option_text: questionForm.optionD.trim(), is_correct: questionForm.correctOption === "D" }, + ].filter((o) => o.option_text) + payload.options = options + return payload + } + + const questionFieldErrors = useMemo(() => { + const errors: { + questionText?: string + points?: string + shortAnswer?: string + options?: string + correctOption?: string + } = {} + if (!questionForm.questionText.trim()) errors.questionText = "Question text is required." + const pts = Number(questionForm.points) + if (!Number.isFinite(pts) || pts < 1) errors.points = "Enter a valid number (minimum 1)." + if (questionForm.questionType === "SHORT" && !questionForm.shortAnswer.trim()) { + errors.shortAnswer = "Expected answer is required for short-answer questions." + } + if (questionForm.questionType === "MCQ") { + const opts = { + A: questionForm.optionA.trim(), + B: questionForm.optionB.trim(), + C: questionForm.optionC.trim(), + D: questionForm.optionD.trim(), + } + const filled = Object.values(opts).filter(Boolean).length + if (filled < 2) errors.options = "Enter at least two non-empty options." + const correct = questionForm.correctOption + if (opts[correct] === "") errors.correctOption = "The marked correct option must include text." + } + return errors + }, [questionForm]) + + const questionCanSave = Object.keys(questionFieldErrors).length === 0 + + const handleSaveQuestion = async () => { + if (!questionDialog.open) return + if (!questionCanSave) { + setQuestionSubmitAttempted(true) + return + } + setSavingQuestion(true) + try { + const payload = buildQuestionPayload() + if (questionDialog.mode === "create") { + const created = await createQuestion(payload) + const questionId = created.data?.data?.id + if (!questionId) throw new Error("Missing created question id") + await addQuestionToSet(questionDialog.practiceId, { question_id: questionId }) + toast.success("Question created") + } else if (questionDialog.questionId) { + await updateQuestion(questionDialog.questionId, payload) + toast.success("Question updated") + } + setQuestionDialog({ open: false }) + setQuestionSubmitAttempted(false) + setQuestionFormTouched(false) + resetQuestionForm() + await Promise.all([ + loadPracticeQuestionsIfNeeded(questionDialog.practiceId, true), + loadHierarchy(), + ]) + } catch (error) { + console.error("Failed to save question:", error) + toast.error("Failed to save question") + } finally { + setSavingQuestion(false) + } + } + + const handleDeletePracticeConfirmed = async () => { + if (!practiceTargetDelete) return + setDeletingPractice(true) + try { + await deletePractice(practiceTargetDelete.id) + toast.success("Practice deleted") + setPracticeTargetDelete(null) + await loadHierarchy() + } catch (error) { + console.error("Failed to delete practice:", error) + toast.error("Failed to delete practice") + } finally { + setDeletingPractice(false) + } + } + + const handleDeleteQuestionConfirmed = async () => { + if (!questionTargetDelete) return + setDeletingQuestion(true) + try { + await deleteQuestion(questionTargetDelete.id) + toast.success("Question deleted") + await Promise.all([ + loadPracticeQuestionsIfNeeded(questionTargetDelete.practiceId, true), + loadHierarchy(), + ]) + setQuestionTargetDelete(null) + } catch (error) { + console.error("Failed to delete question:", error) + toast.error("Failed to delete question") + } finally { + setDeletingQuestion(false) + } + } + const toggleLessonCard = (smKey: string, lessonId: number) => { setSubModuleCardSelection((prev) => { const cur = prev[smKey] ?? { lessonId: null, practiceId: null } @@ -902,7 +1238,8 @@ export function HumanLanguagePage() {
-
+
+
+
+ {panelTab === "practices" ? ( + + ) : null}
@@ -1061,7 +1411,7 @@ export function HumanLanguagePage() { : "border-grayScale-100", )} > -
+
@@ -1083,6 +1433,32 @@ export function HumanLanguagePage() {
+
+ + +
) @@ -1108,6 +1484,16 @@ export function HumanLanguagePage() { ) : null}
+ {practiceFetch?.status === "ok" ? ( {practiceFetch.questions.length} loaded @@ -1179,7 +1565,8 @@ export function HumanLanguagePage() { {qIdx + 1}
-
+
+
) : null} +
+
+ + +

@@ -1319,6 +1738,389 @@ export function HumanLanguagePage() { + +

{ + if (!open) { + setPracticeDialog({ open: false }) + setPracticeSubmitAttempted(false) + setPracticeFormTouched(false) + } + }} + > + + + {practiceDialog.open && practiceDialog.mode === "edit" ? "Edit Practice" : "Create Practice"} + + Manage practice metadata directly from this page. + {!practiceCanSave ? ( + Required fields must be completed before you can save. + ) : null} + + +
+
+ + { + setPracticeFormTouched(true) + setPracticeForm((p) => ({ ...p, title: e.target.value })) + }} + className={cn( + "h-10 w-full rounded-md border px-3 text-sm", + (practiceSubmitAttempted || practiceFormTouched) && practiceFieldErrors.title + ? "border-red-300 ring-1 ring-red-200" + : "border-grayScale-200", + )} + placeholder="Practice title" + aria-invalid={Boolean( + (practiceSubmitAttempted || practiceFormTouched) && practiceFieldErrors.title, + )} + /> + {(practiceSubmitAttempted || practiceFormTouched) && practiceFieldErrors.title ? ( +

{practiceFieldErrors.title}

+ ) : null} +
+
+ +