Compare commits
7 Commits
26e1b0a7d5
...
588b238b49
| Author | SHA1 | Date | |
|---|---|---|---|
| 588b238b49 | |||
| 53c8542a6a | |||
| 7ea8e15266 | |||
| f5a2e2483a | |||
| d308719d18 | |||
| e46e0314ed | |||
| dd6fe3a9c8 |
|
|
@ -1,4 +1,4 @@
|
||||||
# Yimaru Academy LMS Admin Dashboard
|
# Yimaru Academy LMS Admin Panel
|
||||||
|
|
||||||
A modern, feature-rich admin dashboard for managing Yimaru Academy's educational platform. Built with React, TypeScript, and Tailwind CSS.
|
A modern, feature-rich admin dashboard for managing Yimaru Academy's educational platform. Built with React, TypeScript, and Tailwind CSS.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,14 +58,77 @@ import type {
|
||||||
CreateCourseVideoRequest,
|
CreateCourseVideoRequest,
|
||||||
} from "../types/course.types"
|
} from "../types/course.types"
|
||||||
|
|
||||||
|
type UnifiedHierarchyRow = {
|
||||||
|
category_id: number
|
||||||
|
category_name: string
|
||||||
|
sub_category_id?: number | null
|
||||||
|
sub_category_name?: string | null
|
||||||
|
course_id?: number | null
|
||||||
|
course_title?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type CourseHierarchyRow = {
|
||||||
|
course_id: number
|
||||||
|
course_title: string
|
||||||
|
level_id?: number | null
|
||||||
|
cefr_level?: string | null
|
||||||
|
module_id?: number | null
|
||||||
|
module_title?: string | null
|
||||||
|
sub_module_id?: number | null
|
||||||
|
sub_module_title?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export const getCourseCategories = () =>
|
export const getCourseCategories = () =>
|
||||||
http.get<GetCourseCategoriesResponse>("/course-management/categories")
|
http.get("/course-management/hierarchy").then((res) => {
|
||||||
|
const rows: UnifiedHierarchyRow[] = res.data?.data ?? []
|
||||||
|
const categoriesMap = new Map<number, { id: number; name: string; is_active: boolean; created_at: string }>()
|
||||||
|
rows.forEach((r) => {
|
||||||
|
if (!categoriesMap.has(r.category_id)) {
|
||||||
|
categoriesMap.set(r.category_id, {
|
||||||
|
id: r.category_id,
|
||||||
|
name: r.category_name,
|
||||||
|
is_active: true,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const categories = Array.from(categoriesMap.values())
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
data: {
|
||||||
|
...res.data,
|
||||||
|
data: {
|
||||||
|
categories,
|
||||||
|
total_count: categories.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as { data: GetCourseCategoriesResponse }
|
||||||
|
})
|
||||||
|
|
||||||
export const createCourseCategory = (data: CreateCourseCategoryRequest) =>
|
export const createCourseCategory = (data: CreateCourseCategoryRequest) =>
|
||||||
http.post("/course-management/categories", data)
|
data.parent_id
|
||||||
|
? http.post("/course-management/sub-categories", { category_id: data.parent_id, name: data.name })
|
||||||
|
: http.post("/course-management/categories", { name: data.name })
|
||||||
|
|
||||||
export const getCoursesByCategory = (categoryId: number) =>
|
export const getCoursesByCategory = (categoryId: number) =>
|
||||||
http.get<GetCoursesResponse>(`/course-management/categories/${categoryId}/courses`)
|
http.get("/course-management/hierarchy").then((res) => {
|
||||||
|
const rows: UnifiedHierarchyRow[] = res.data?.data ?? []
|
||||||
|
const courses = rows
|
||||||
|
.filter((r) => r.category_id === categoryId && r.course_id)
|
||||||
|
.map((r) => ({
|
||||||
|
id: Number(r.course_id),
|
||||||
|
category_id: r.category_id,
|
||||||
|
sub_category_id: r.sub_category_id ?? null,
|
||||||
|
title: r.course_title ?? "",
|
||||||
|
description: "",
|
||||||
|
thumbnail: "",
|
||||||
|
is_active: true,
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
data: { ...res.data, data: { courses, total_count: courses.length } },
|
||||||
|
} as unknown as { data: GetCoursesResponse }
|
||||||
|
})
|
||||||
|
|
||||||
export const createCourse = (data: CreateCourseRequest) =>
|
export const createCourse = (data: CreateCourseRequest) =>
|
||||||
http.post("/course-management/courses", data)
|
http.post("/course-management/courses", data)
|
||||||
|
|
@ -84,58 +147,142 @@ export const updateCourseStatus = (courseId: number, isActive: boolean) =>
|
||||||
export const updateCourse = (courseId: number, data: UpdateCourseRequest) =>
|
export const updateCourse = (courseId: number, data: UpdateCourseRequest) =>
|
||||||
http.put(`/course-management/courses/${courseId}`, data)
|
http.put(`/course-management/courses/${courseId}`, data)
|
||||||
|
|
||||||
// SubCourse APIs (New Hierarchy)
|
// Sub-Module APIs (Unified Hierarchy)
|
||||||
export const getSubCoursesByCourse = (courseId: number) =>
|
export const getSubModulesByCourse = (courseId: number) =>
|
||||||
http.get<GetSubCoursesResponse>(`/course-management/courses/${courseId}/sub-courses`)
|
http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => {
|
||||||
|
const rows: CourseHierarchyRow[] = res.data?.data ?? []
|
||||||
|
const subModuleMap = new Map<number, { id: number; course_id: number; module_id?: number; title: string; description: string; level: string; cefr_level?: string; thumbnail: string; display_order: number; sub_level?: string; is_active: boolean }>()
|
||||||
|
rows.forEach((r, idx) => {
|
||||||
|
if (!r.sub_module_id) return
|
||||||
|
if (!subModuleMap.has(r.sub_module_id)) {
|
||||||
|
subModuleMap.set(r.sub_module_id, {
|
||||||
|
id: r.sub_module_id,
|
||||||
|
course_id: courseId,
|
||||||
|
module_id: r.module_id ?? undefined,
|
||||||
|
title: r.sub_module_title ?? "",
|
||||||
|
description: "",
|
||||||
|
level: r.cefr_level ?? "",
|
||||||
|
cefr_level: r.cefr_level ?? undefined,
|
||||||
|
thumbnail: "",
|
||||||
|
display_order: idx + 1,
|
||||||
|
sub_level: r.cefr_level ?? undefined,
|
||||||
|
is_active: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const sub_courses = Array.from(subModuleMap.values())
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
data: {
|
||||||
|
...res.data,
|
||||||
|
data: { sub_courses, total_count: sub_courses.length },
|
||||||
|
},
|
||||||
|
} as unknown as { data: GetSubCoursesResponse }
|
||||||
|
})
|
||||||
|
|
||||||
export const createSubCourse = (data: CreateSubCourseRequest) =>
|
export const createSubModule = (data: CreateSubCourseRequest) =>
|
||||||
http.post("/course-management/sub-courses", data)
|
http
|
||||||
|
.post("/course-management/levels", {
|
||||||
|
course_id: data.course_id,
|
||||||
|
cefr_level: data.sub_level || data.level,
|
||||||
|
display_order: data.display_order ?? 0,
|
||||||
|
is_active: true,
|
||||||
|
})
|
||||||
|
.then((levelRes) =>
|
||||||
|
http.post("/course-management/modules", {
|
||||||
|
level_id: levelRes.data?.data?.id,
|
||||||
|
title: `${data.sub_level || data.level} Module`,
|
||||||
|
description: `${data.title} container module`,
|
||||||
|
display_order: 1,
|
||||||
|
is_active: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then((moduleRes) =>
|
||||||
|
http.post("/course-management/sub-modules", {
|
||||||
|
module_id: moduleRes.data?.data?.id,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
display_order: data.display_order ?? 0,
|
||||||
|
is_active: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export const updateSubCourseThumbnail = (subCourseId: number, thumbnailUrl: string) =>
|
export const updateSubModuleThumbnail = (subModuleId: number, thumbnailUrl: string) =>
|
||||||
http.post(`/course-management/sub-courses/${subCourseId}/thumbnail`, {
|
http.post(`/course-management/sub-courses/${subModuleId}/thumbnail`, {
|
||||||
thumbnail_url: thumbnailUrl,
|
thumbnail_url: thumbnailUrl,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const updateSubCourse = (subCourseId: number, data: UpdateSubCourseRequest) =>
|
export const updateSubModule = (subModuleId: number, data: UpdateSubCourseRequest) =>
|
||||||
http.patch(`/course-management/sub-courses/${subCourseId}`, data)
|
http.put(`/course-management/sub-modules/${subModuleId}`, data)
|
||||||
|
|
||||||
export const updateSubCourseStatus = (subCourseId: number, data: UpdateSubCourseStatusRequest) =>
|
export const updateSubModuleStatus = (subModuleId: number, data: UpdateSubCourseStatusRequest) =>
|
||||||
http.patch(`/course-management/sub-courses/${subCourseId}`, data)
|
http.put(`/course-management/sub-modules/${subModuleId}`, data)
|
||||||
|
|
||||||
export const deleteSubCourse = (subCourseId: number) =>
|
export const deleteSubModule = (subModuleId: number) =>
|
||||||
http.delete(`/course-management/sub-courses/${subCourseId}`)
|
http.delete(`/course-management/sub-modules/${subModuleId}`)
|
||||||
|
|
||||||
// SubCourse Video APIs
|
// Sub-Module Video APIs
|
||||||
export const getVideosBySubCourse = (subCourseId: number) =>
|
export const getVideosBySubModule = (subModuleId: number) =>
|
||||||
http.get<GetSubCourseVideosResponse>(`/course-management/sub-courses/${subCourseId}/videos`)
|
http.get<GetSubCourseVideosResponse>(`/course-management/sub-modules/${subModuleId}/videos`)
|
||||||
|
|
||||||
export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) =>
|
export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) =>
|
||||||
http.post("/course-management/sub-course-videos", data)
|
http.post("/course-management/sub-module-videos", {
|
||||||
|
sub_module_id: data.sub_module_id ?? data.sub_course_id,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
video_url: data.video_url,
|
||||||
|
})
|
||||||
|
|
||||||
export const createCourseVideo = (data: CreateCourseVideoRequest) =>
|
export const createCourseVideo = (data: CreateCourseVideoRequest) =>
|
||||||
http.post("/course-management/videos", data)
|
http.post("/course-management/sub-module-videos", {
|
||||||
|
sub_module_id: data.sub_module_id ?? data.sub_course_id,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
video_url: data.video_url,
|
||||||
|
duration: data.duration,
|
||||||
|
resolution: data.resolution,
|
||||||
|
visibility: data.visibility,
|
||||||
|
display_order: data.display_order,
|
||||||
|
status: data.status,
|
||||||
|
})
|
||||||
|
|
||||||
export const updateSubCourseVideo = (videoId: number, data: UpdateSubCourseVideoRequest) =>
|
export const updateSubCourseVideo = (videoId: number, data: UpdateSubCourseVideoRequest) =>
|
||||||
http.put(`/course-management/sub-course-videos/${videoId}`, data)
|
http.put(`/course-management/sub-module-videos/${videoId}`, data)
|
||||||
|
|
||||||
export const deleteSubCourseVideo = (videoId: number) =>
|
export const deleteSubCourseVideo = (videoId: number) =>
|
||||||
http.delete(`/course-management/sub-course-videos/${videoId}`)
|
http.delete(`/course-management/sub-module-videos/${videoId}`)
|
||||||
|
|
||||||
// Practice APIs - for SubCourse practices (New Hierarchy)
|
// Practice APIs - for Sub-Module practices (Unified Hierarchy)
|
||||||
// Practices are question sets: POST /question-sets with set_type: "PRACTICE", owner_type: "SUB_COURSE".
|
export const getPracticesBySubModule = (subModuleId: number) =>
|
||||||
export const getPracticesBySubCourse = (subCourseId: number) =>
|
|
||||||
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
|
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
|
||||||
params: { owner_type: "SUB_COURSE", owner_id: subCourseId },
|
params: { owner_type: "SUB_MODULE", owner_id: subModuleId },
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createPractice = (data: CreatePracticeRequest) =>
|
export const createPractice = (data: CreatePracticeRequest) =>
|
||||||
http.post<CreateQuestionSetResponse>("/question-sets", {
|
http
|
||||||
|
.post<CreateQuestionSetResponse>("/question-sets", {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
set_type: "PRACTICE",
|
set_type: "PRACTICE",
|
||||||
owner_type: "SUB_COURSE",
|
owner_type: "SUB_MODULE",
|
||||||
owner_id: data.sub_course_id,
|
owner_id: data.sub_module_id ?? data.sub_course_id,
|
||||||
...(data.description?.trim() ? { description: data.description.trim() } : {}),
|
...(data.description?.trim() ? { description: data.description.trim() } : {}),
|
||||||
...(data.persona ? { persona: data.persona } : {}),
|
...(data.persona ? { persona: data.persona } : {}),
|
||||||
|
...(data.intro_video_url?.trim() ? { intro_video_url: data.intro_video_url.trim() } : {}),
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
const questionSetID = res.data?.data?.id
|
||||||
|
const subModuleID = data.sub_module_id ?? data.sub_course_id
|
||||||
|
if (!questionSetID || !subModuleID) return res
|
||||||
|
return http
|
||||||
|
.post("/course-management/sub-module-practices", {
|
||||||
|
sub_module_id: subModuleID,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
thumbnail: data.thumbnail,
|
||||||
|
intro_video_url: data.intro_video_url,
|
||||||
|
question_set_id: questionSetID,
|
||||||
|
})
|
||||||
|
.then(() => res)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const updatePractice = (practiceId: number, data: UpdatePracticeRequest) =>
|
export const updatePractice = (practiceId: number, data: UpdatePracticeRequest) =>
|
||||||
|
|
@ -160,13 +307,43 @@ export const getPracticeQuestionsByPractice = (
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createPracticeQuestion = (data: CreatePracticeQuestionRequest) =>
|
export const createPracticeQuestion = (data: CreatePracticeQuestionRequest) =>
|
||||||
http.post("/course-management/practice-questions", data)
|
http
|
||||||
|
.post<CreateQuestionResponse>("/questions", {
|
||||||
|
question_text: data.question,
|
||||||
|
question_type: data.type === "SHORT" ? "SHORT_ANSWER" : data.type,
|
||||||
|
points: data.points ?? 1,
|
||||||
|
difficulty_level: data.difficulty_level,
|
||||||
|
tips: data.tips,
|
||||||
|
explanation: data.explanation,
|
||||||
|
voice_prompt: data.question_voice_prompt,
|
||||||
|
sample_answer_voice_prompt: data.sample_answer_voice_prompt,
|
||||||
|
audio_correct_answer_text: data.sample_answer,
|
||||||
|
options: data.options,
|
||||||
|
short_answers: data.short_answers,
|
||||||
|
})
|
||||||
|
.then((res) =>
|
||||||
|
http.post(`/question-sets/${data.practice_id}/questions`, {
|
||||||
|
question_id: res.data?.data?.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export const updatePracticeQuestion = (questionId: number, data: UpdatePracticeQuestionRequest) =>
|
export const updatePracticeQuestion = (questionId: number, data: UpdatePracticeQuestionRequest) =>
|
||||||
http.put(`/course-management/practice-questions/${questionId}`, data)
|
http.put(`/questions/${questionId}`, {
|
||||||
|
question_text: data.question,
|
||||||
|
question_type: data.type === "SHORT" ? "SHORT_ANSWER" : data.type,
|
||||||
|
points: data.points ?? 1,
|
||||||
|
difficulty_level: data.difficulty_level,
|
||||||
|
tips: data.tips,
|
||||||
|
explanation: data.explanation,
|
||||||
|
voice_prompt: data.question_voice_prompt,
|
||||||
|
sample_answer_voice_prompt: data.sample_answer_voice_prompt,
|
||||||
|
audio_correct_answer_text: data.sample_answer,
|
||||||
|
options: data.options,
|
||||||
|
short_answers: data.short_answers,
|
||||||
|
})
|
||||||
|
|
||||||
export const deletePracticeQuestion = (questionId: number) =>
|
export const deletePracticeQuestion = (questionId: number) =>
|
||||||
http.delete(`/course-management/practice-questions/${questionId}`)
|
http.delete(`/questions/${questionId}`)
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Legacy APIs (deprecated - using SubCourse hierarchy now)
|
// Legacy APIs (deprecated - using SubCourse hierarchy now)
|
||||||
|
|
@ -230,7 +407,10 @@ export const getQuestionSets = (params?: GetQuestionSetsParams) =>
|
||||||
|
|
||||||
export const getQuestionSetsByOwner = (ownerType: string, ownerId: number) =>
|
export const getQuestionSetsByOwner = (ownerType: string, ownerId: number) =>
|
||||||
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
|
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
|
||||||
params: { owner_type: ownerType, owner_id: ownerId },
|
params: {
|
||||||
|
owner_type: ownerType === "SUB_COURSE" ? "SUB_MODULE" : ownerType,
|
||||||
|
owner_id: ownerId,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getQuestionSetById = (questionSetId: number) =>
|
export const getQuestionSetById = (questionSetId: number) =>
|
||||||
|
|
@ -276,17 +456,46 @@ export const deleteQuestionSet = (questionSetId: number) =>
|
||||||
http.delete(`/question-sets/${questionSetId}`)
|
http.delete(`/question-sets/${questionSetId}`)
|
||||||
|
|
||||||
export const createVimeoVideo = (data: CreateVimeoVideoRequest) =>
|
export const createVimeoVideo = (data: CreateVimeoVideoRequest) =>
|
||||||
http.post("/course-management/videos/vimeo", data)
|
http.post("/vimeo/uploads/pull", {
|
||||||
|
name: data.title,
|
||||||
|
description: data.description,
|
||||||
|
source_url: data.source_url,
|
||||||
|
file_size: data.file_size,
|
||||||
|
})
|
||||||
|
|
||||||
// Sub-course Prerequisite APIs
|
// Sub-module Prerequisite APIs
|
||||||
export const getSubCoursePrerequisites = (subCourseId: number) =>
|
export const getSubModulePrerequisites = (subModuleId: number) =>
|
||||||
http.get<GetSubCoursePrerequisitesResponse>(`/course-management/sub-courses/${subCourseId}/prerequisites`)
|
Promise.resolve({
|
||||||
|
data: {
|
||||||
|
message: "Sub-module prerequisites are not supported by this backend yet",
|
||||||
|
data: { prerequisites: [], total_count: 0 },
|
||||||
|
success: true,
|
||||||
|
status_code: 200,
|
||||||
|
metadata: { sub_module_id: subModuleId },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const addSubCoursePrerequisite = (subCourseId: number, data: AddSubCoursePrerequisiteRequest) =>
|
export const addSubModulePrerequisite = (subModuleId: number, data: AddSubCoursePrerequisiteRequest) =>
|
||||||
http.post(`/course-management/sub-courses/${subCourseId}/prerequisites`, data)
|
Promise.resolve({
|
||||||
|
data: {
|
||||||
|
message: "Sub-module prerequisites are not supported by this backend yet",
|
||||||
|
data: { sub_module_id: subModuleId, prerequisite_sub_module_id: data.prerequisite_sub_course_id },
|
||||||
|
success: true,
|
||||||
|
status_code: 200,
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const removeSubCoursePrerequisite = (subCourseId: number, prerequisiteId: number) =>
|
export const removeSubModulePrerequisite = (subModuleId: number, prerequisiteId: number) =>
|
||||||
http.delete(`/course-management/sub-courses/${subCourseId}/prerequisites/${prerequisiteId}`)
|
Promise.resolve({
|
||||||
|
data: {
|
||||||
|
message: "Sub-module prerequisites are not supported by this backend yet",
|
||||||
|
data: { sub_module_id: subModuleId, prerequisite_id: prerequisiteId },
|
||||||
|
success: true,
|
||||||
|
status_code: 200,
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// Learning Path APIs
|
// Learning Path APIs
|
||||||
export const getLearningPath = (courseId: number) =>
|
export const getLearningPath = (courseId: number) =>
|
||||||
|
|
@ -298,14 +507,216 @@ export const getHumanLanguageLessonsByCourse = (courseId: number, cefr_level: st
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getHumanLanguageHierarchy = () =>
|
export const getHumanLanguageHierarchy = () =>
|
||||||
http.get<GetHumanLanguageHierarchyResponse>("/course-management/human-language/hierarchy")
|
http.get<GetHumanLanguageHierarchyResponse>("/course-management/hierarchy").then(async (res) => {
|
||||||
|
const payload = res.data?.data as unknown
|
||||||
|
if (payload && typeof payload === "object" && !Array.isArray(payload) && "sub_categories" in payload) {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: UnifiedHierarchyRow[] = Array.isArray(payload) ? payload : []
|
||||||
|
const categoryMap = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
category_id: number
|
||||||
|
category_name: string
|
||||||
|
sub_categories: Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
sub_category_id: number
|
||||||
|
sub_category_name: string
|
||||||
|
courses: Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
course_id: number
|
||||||
|
course_name: string
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const categoryId = Number(row.category_id)
|
||||||
|
if (!Number.isFinite(categoryId)) return
|
||||||
|
|
||||||
|
if (!categoryMap.has(categoryId)) {
|
||||||
|
categoryMap.set(categoryId, {
|
||||||
|
category_id: categoryId,
|
||||||
|
category_name: row.category_name ?? "",
|
||||||
|
sub_categories: new Map(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.sub_category_id) return
|
||||||
|
const subCategoryId = Number(row.sub_category_id)
|
||||||
|
if (!Number.isFinite(subCategoryId)) return
|
||||||
|
|
||||||
|
const categoryNode = categoryMap.get(categoryId)!
|
||||||
|
if (!categoryNode.sub_categories.has(subCategoryId)) {
|
||||||
|
categoryNode.sub_categories.set(subCategoryId, {
|
||||||
|
sub_category_id: subCategoryId,
|
||||||
|
sub_category_name: row.sub_category_name ?? "",
|
||||||
|
courses: new Map(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.course_id) return
|
||||||
|
const courseId = Number(row.course_id)
|
||||||
|
if (!Number.isFinite(courseId)) return
|
||||||
|
|
||||||
|
const subCategoryNode = categoryNode.sub_categories.get(subCategoryId)!
|
||||||
|
if (!subCategoryNode.courses.has(courseId)) {
|
||||||
|
subCategoryNode.courses.set(courseId, {
|
||||||
|
course_id: courseId,
|
||||||
|
course_name: row.course_title ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedCategory =
|
||||||
|
Array.from(categoryMap.values()).find((c) => c.category_name.toLowerCase().includes("human")) ??
|
||||||
|
Array.from(categoryMap.values())[0]
|
||||||
|
|
||||||
|
if (!selectedCategory) {
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
data: {
|
||||||
|
...res.data,
|
||||||
|
data: {
|
||||||
|
category_id: 0,
|
||||||
|
category_name: "",
|
||||||
|
sub_categories: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as { data: GetHumanLanguageHierarchyResponse }
|
||||||
|
}
|
||||||
|
|
||||||
|
const courses = Array.from(selectedCategory.sub_categories.values()).flatMap((sub) =>
|
||||||
|
Array.from(sub.courses.values()).map((course) => ({ sub_category_id: sub.sub_category_id, course })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const hierarchyResponses = await Promise.all(
|
||||||
|
courses.map(({ course }) =>
|
||||||
|
http
|
||||||
|
.get(`/course-management/courses/${course.course_id}/hierarchy`)
|
||||||
|
.then((courseRes) => ({ course_id: course.course_id, rows: (courseRes.data?.data ?? []) as CourseHierarchyRow[] }))
|
||||||
|
.catch(() => ({ course_id: course.course_id, rows: [] as CourseHierarchyRow[] })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const hierarchyByCourse = new Map<number, CourseHierarchyRow[]>(
|
||||||
|
hierarchyResponses.map((h) => [h.course_id, h.rows]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const subCategories = Array.from(selectedCategory.sub_categories.values()).map((sub) => ({
|
||||||
|
sub_category_id: sub.sub_category_id,
|
||||||
|
sub_category_name: sub.sub_category_name,
|
||||||
|
courses: Array.from(sub.courses.values()).map((course) => {
|
||||||
|
const levelMap = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
level: string
|
||||||
|
modules: Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
sub_modules: Map<number, { id: number; title: string; videos: []; practices: [] }>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
;(hierarchyByCourse.get(course.course_id) ?? []).forEach((row) => {
|
||||||
|
if (!row.level_id || !row.cefr_level) return
|
||||||
|
const levelKey = String(row.cefr_level).toUpperCase()
|
||||||
|
if (!levelMap.has(levelKey)) {
|
||||||
|
levelMap.set(levelKey, { level: levelKey, modules: new Map() })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.module_id) return
|
||||||
|
const levelNode = levelMap.get(levelKey)!
|
||||||
|
const moduleId = Number(row.module_id)
|
||||||
|
if (!levelNode.modules.has(moduleId)) {
|
||||||
|
levelNode.modules.set(moduleId, {
|
||||||
|
id: moduleId,
|
||||||
|
title: row.module_title ?? "",
|
||||||
|
sub_modules: new Map(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.sub_module_id) return
|
||||||
|
const moduleNode = levelNode.modules.get(moduleId)!
|
||||||
|
const subModuleId = Number(row.sub_module_id)
|
||||||
|
if (!moduleNode.sub_modules.has(subModuleId)) {
|
||||||
|
moduleNode.sub_modules.set(subModuleId, {
|
||||||
|
id: subModuleId,
|
||||||
|
title: row.sub_module_title ?? "",
|
||||||
|
videos: [],
|
||||||
|
practices: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
course_id: course.course_id,
|
||||||
|
course_name: course.course_name,
|
||||||
|
levels: Array.from(levelMap.values()).map((levelNode) => ({
|
||||||
|
level: levelNode.level,
|
||||||
|
modules: Array.from(levelNode.modules.values()).map((moduleNode) => ({
|
||||||
|
id: moduleNode.id,
|
||||||
|
title: moduleNode.title,
|
||||||
|
sub_modules: Array.from(moduleNode.sub_modules.values()),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
data: {
|
||||||
|
...res.data,
|
||||||
|
data: {
|
||||||
|
category_id: selectedCategory.category_id,
|
||||||
|
category_name: selectedCategory.category_name,
|
||||||
|
sub_categories: subCategories,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as { data: GetHumanLanguageHierarchyResponse }
|
||||||
|
})
|
||||||
|
|
||||||
export const createHumanLanguageLesson = (data: CreateHumanLanguageLessonRequest) =>
|
export const createHumanLanguageLesson = (data: CreateHumanLanguageLessonRequest) =>
|
||||||
http.post("/course-management/human-language/lessons", data)
|
http
|
||||||
|
.post("/course-management/levels", {
|
||||||
|
course_id: data.course_id,
|
||||||
|
cefr_level: data.cefr_level,
|
||||||
|
display_order: data.display_order ?? 0,
|
||||||
|
is_active: true,
|
||||||
|
})
|
||||||
|
.then((levelRes) =>
|
||||||
|
http.post("/course-management/modules", {
|
||||||
|
level_id: levelRes.data?.data?.id,
|
||||||
|
title: `${data.cefr_level} Module`,
|
||||||
|
description: "Generated module for CEFR level",
|
||||||
|
display_order: 1,
|
||||||
|
is_active: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then((moduleRes) =>
|
||||||
|
http.post("/course-management/sub-modules", {
|
||||||
|
module_id: moduleRes.data?.data?.id,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description ?? "",
|
||||||
|
display_order: data.display_order ?? 0,
|
||||||
|
is_active: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export const getSubCourseEntryAssessment = (subCourseId: number) =>
|
export const getSubModuleEntryAssessment = (subModuleId: number) =>
|
||||||
http.get<GetSubCourseEntryAssessmentResponse>(
|
http.get<GetSubCourseEntryAssessmentResponse>(
|
||||||
`/question-sets/sub-courses/${subCourseId}/entry-assessment`,
|
`/question-sets/sub-courses/${subModuleId}/entry-assessment`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const buildReorderPayload = (items: ReorderItem[]) => {
|
const buildReorderPayload = (items: ReorderItem[]) => {
|
||||||
|
|
@ -329,20 +740,32 @@ const buildReorderPayload = (items: ReorderItem[]) => {
|
||||||
return { items: normalized }
|
return { items: normalized }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const reorderCategories = (items: ReorderItem[]) =>
|
const reorderNotYetSupported = (items: ReorderItem[]) => Promise.resolve({ data: { data: buildReorderPayload(items) } })
|
||||||
http.put("/course-management/categories/reorder", buildReorderPayload(items))
|
|
||||||
|
|
||||||
export const reorderCourses = (items: ReorderItem[]) =>
|
export const reorderCategories = reorderNotYetSupported
|
||||||
http.put("/course-management/courses/reorder", buildReorderPayload(items))
|
|
||||||
|
|
||||||
export const reorderSubCourses = (items: ReorderItem[]) =>
|
export const reorderCourses = reorderNotYetSupported
|
||||||
http.put("/course-management/sub-courses/reorder", buildReorderPayload(items))
|
|
||||||
|
|
||||||
export const reorderVideos = (items: ReorderItem[]) =>
|
export const reorderSubModules = reorderNotYetSupported
|
||||||
http.put("/course-management/videos/reorder", buildReorderPayload(items))
|
|
||||||
|
|
||||||
export const reorderPractices = (items: ReorderItem[]) =>
|
// Backward-compatible aliases
|
||||||
http.put("/course-management/practices/reorder", buildReorderPayload(items))
|
export const getSubCoursesByCourse = getSubModulesByCourse
|
||||||
|
export const createSubCourse = createSubModule
|
||||||
|
export const updateSubCourseThumbnail = updateSubModuleThumbnail
|
||||||
|
export const updateSubCourse = updateSubModule
|
||||||
|
export const updateSubCourseStatus = updateSubModuleStatus
|
||||||
|
export const deleteSubCourse = deleteSubModule
|
||||||
|
export const getVideosBySubCourse = getVideosBySubModule
|
||||||
|
export const getPracticesBySubCourse = getPracticesBySubModule
|
||||||
|
export const getSubCoursePrerequisites = getSubModulePrerequisites
|
||||||
|
export const addSubCoursePrerequisite = addSubModulePrerequisite
|
||||||
|
export const removeSubCoursePrerequisite = removeSubModulePrerequisite
|
||||||
|
export const getSubCourseEntryAssessment = getSubModuleEntryAssessment
|
||||||
|
export const reorderSubCourses = reorderSubModules
|
||||||
|
|
||||||
|
export const reorderVideos = reorderNotYetSupported
|
||||||
|
|
||||||
|
export const reorderPractices = reorderNotYetSupported
|
||||||
|
|
||||||
// Ratings
|
// Ratings
|
||||||
export const getRatings = (params: GetRatingsParams) =>
|
export const getRatings = (params: GetRatingsParams) =>
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import { ContentOverviewPage } from "../pages/content-management/ContentOverview
|
||||||
import { CoursesPage } from "../pages/content-management/CoursesPage"
|
import { CoursesPage } from "../pages/content-management/CoursesPage"
|
||||||
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage"
|
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage"
|
||||||
import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage"
|
import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage"
|
||||||
import { SubCoursesPage } from "../pages/content-management/SubCoursesPage"
|
import { SubModulesPage } from "../pages/content-management/SubCoursesPage"
|
||||||
import { SubCourseContentPage } from "../pages/content-management/SubCourseContentPage"
|
import { SubModuleContentPage } from "../pages/content-management/SubCourseContentPage"
|
||||||
import { SpeakingPage } from "../pages/content-management/SpeakingPage"
|
import { SpeakingPage } from "../pages/content-management/SpeakingPage"
|
||||||
import { AddVideoPage } from "../pages/content-management/AddVideoPage"
|
import { AddVideoPage } from "../pages/content-management/AddVideoPage"
|
||||||
import { AddPracticePage } from "../pages/content-management/AddPracticePage"
|
import { AddPracticePage } from "../pages/content-management/AddPracticePage"
|
||||||
|
|
@ -80,24 +80,29 @@ export function AppRoutes() {
|
||||||
<Route path="flows" element={<CourseFlowBuilderPage />} />
|
<Route path="flows" element={<CourseFlowBuilderPage />} />
|
||||||
<Route path="human-language" element={<HumanLanguagePage />} />
|
<Route path="human-language" element={<HumanLanguagePage />} />
|
||||||
<Route
|
<Route
|
||||||
path="human-language/:categoryId/:courseId/sub-module/:subCourseId/add-practice"
|
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice"
|
||||||
element={<AddNewPracticePage />}
|
element={<AddNewPracticePage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="human-language/:categoryId/:courseId/sub-module/:subCourseId/practices/:practiceId/questions"
|
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/practices/:practiceId/questions"
|
||||||
element={<PracticeQuestionsPage />}
|
element={<PracticeQuestionsPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="human-language/:categoryId/:courseId/sub-module/:subCourseId"
|
path="human-language/:categoryId/:courseId/sub-module/:subModuleId"
|
||||||
element={<HumanLanguageSubModulePage />}
|
element={<HumanLanguageSubModulePage />}
|
||||||
/>
|
/>
|
||||||
<Route path="category/:categoryId" element={<ContentOverviewPage />} />
|
<Route path="category/:categoryId" element={<ContentOverviewPage />} />
|
||||||
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
|
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
|
||||||
{/* Course → Sub-course → Video/Practice */}
|
{/* Course → Sub-module → Lesson/Practice */}
|
||||||
<Route path="category/:categoryId/courses/:courseId/sub-courses" element={<SubCoursesPage />} />
|
<Route path="category/:categoryId/courses/:courseId/sub-modules" element={<SubModulesPage />} />
|
||||||
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subCourseId" element={<SubCourseContentPage />} />
|
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId" element={<SubModuleContentPage />} />
|
||||||
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subCourseId/add-practice" element={<AddNewPracticePage />} />
|
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-practice" element={<AddNewPracticePage />} />
|
||||||
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subCourseId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
|
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
|
||||||
|
{/* Legacy aliases */}
|
||||||
|
<Route path="category/:categoryId/courses/:courseId/sub-courses" element={<SubModulesPage />} />
|
||||||
|
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId" element={<SubModuleContentPage />} />
|
||||||
|
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-practice" element={<AddNewPracticePage />} />
|
||||||
|
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
|
||||||
<Route path="category/:categoryId/courses/add-video" element={<AddVideoPage />} />
|
<Route path="category/:categoryId/courses/add-video" element={<AddVideoPage />} />
|
||||||
<Route path="speaking" element={<SpeakingPage />} />
|
<Route path="speaking" element={<SpeakingPage />} />
|
||||||
<Route path="speaking/add-practice" element={<AddPracticePage />} />
|
<Route path="speaking/add-practice" element={<AddPracticePage />} />
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ export function DashboardPage() {
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
label="Courses"
|
label="Courses"
|
||||||
value={dashboard.courses.total_courses.toLocaleString()}
|
value={dashboard.courses.total_courses.toLocaleString()}
|
||||||
deltaLabel={`${dashboard.courses.total_sub_courses} sub-courses, ${dashboard.courses.total_videos} videos`}
|
deltaLabel={`${dashboard.courses.total_sub_courses} sub-modules, ${dashboard.courses.total_videos} videos`}
|
||||||
deltaPositive
|
deltaPositive
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
|
|
|
||||||
|
|
@ -70,21 +70,26 @@ function KpiCard({
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className={cn("shadow-none transition-shadow hover:shadow-md", className)}>
|
<Card
|
||||||
<CardContent className="p-4">
|
className={cn(
|
||||||
|
"border-grayScale-100/90 bg-white shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-5">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-xs font-medium text-grayScale-500">{label}</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-grayScale-400">{label}</div>
|
||||||
<div className="mt-1 text-2xl font-semibold tracking-tight">{value}</div>
|
<div className="mt-1.5 text-[1.75rem] font-semibold leading-none tracking-tight text-grayScale-800">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
|
<div className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-50 to-brand-100 text-brand-600 ring-1 ring-brand-100">
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{sub && (
|
{sub && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-2 flex items-center gap-1 text-xs font-medium",
|
"mt-3 flex items-center gap-1 text-xs font-medium",
|
||||||
trend === "up" && "text-mint-500",
|
trend === "up" && "text-mint-500",
|
||||||
trend === "down" && "text-destructive",
|
trend === "down" && "text-destructive",
|
||||||
(!trend || trend === "neutral") && "text-grayScale-400",
|
(!trend || trend === "neutral") && "text-grayScale-400",
|
||||||
|
|
@ -244,16 +249,16 @@ function Section({
|
||||||
const [open, setOpen] = useState(defaultOpen)
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-white">
|
<div className="rounded-2xl border border-grayScale-100 bg-white shadow-sm">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="flex w-full items-center gap-3 px-5 py-3.5 text-left transition-colors hover:bg-grayScale-50"
|
className="flex w-full items-center gap-3 px-6 py-4 text-left transition-colors hover:bg-grayScale-50/80"
|
||||||
>
|
>
|
||||||
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-brand-100 text-brand-600">
|
<div className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-gradient-to-br from-brand-50 to-brand-100 text-brand-600 ring-1 ring-brand-100">
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="flex-1 text-sm font-semibold text-grayScale-800">{title}</span>
|
<span className="flex-1 text-sm font-semibold tracking-wide text-grayScale-800">{title}</span>
|
||||||
{count !== undefined && (
|
{count !== undefined && (
|
||||||
<Badge variant="secondary" className="mr-2 text-[10px]">
|
<Badge variant="secondary" className="mr-2 text-[10px]">
|
||||||
{count}
|
{count}
|
||||||
|
|
@ -273,7 +278,7 @@ function Section({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<div className="px-5 pb-5 pt-1">{children}</div>
|
<div className="border-t border-grayScale-100 px-6 pb-6 pt-4">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -305,9 +310,9 @@ export function AnalyticsPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl">
|
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
|
||||||
<div className="mb-4 text-sm font-semibold text-grayScale-500">Analytics</div>
|
<div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-grayScale-100 bg-white py-24 shadow-sm">
|
||||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||||
<span className="text-sm font-medium text-grayScale-400">Loading analytics…</span>
|
<span className="text-sm font-medium text-grayScale-400">Loading analytics…</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -317,9 +322,9 @@ export function AnalyticsPage() {
|
||||||
|
|
||||||
if (error || !dashboard) {
|
if (error || !dashboard) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl">
|
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
|
||||||
<div className="mb-4 text-sm font-semibold text-grayScale-500">Analytics</div>
|
<div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-red-100 bg-red-50/30 py-24">
|
||||||
<img src={alertSrc} alt="" className="h-12 w-12" />
|
<img src={alertSrc} alt="" className="h-12 w-12" />
|
||||||
<span className="text-sm text-destructive">Failed to load analytics data.</span>
|
<span className="text-sm text-destructive">Failed to load analytics data.</span>
|
||||||
<Button variant="outline" size="sm" onClick={fetchData}>
|
<Button variant="outline" size="sm" onClick={fetchData}>
|
||||||
|
|
@ -375,12 +380,12 @@ export function AnalyticsPage() {
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl">
|
<div className="mx-auto w-full max-w-[1280px] px-2 pb-6 sm:px-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-5 flex items-end justify-between">
|
<div className="mb-7 flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 text-sm font-semibold text-grayScale-500">Analytics</div>
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Platform Overview</h1>
|
<h1 className="text-3xl font-semibold tracking-tight text-grayScale-900">Platform Overview</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-grayScale-400">Generated {generatedAt}</span>
|
<span className="text-xs text-grayScale-400">Generated {generatedAt}</span>
|
||||||
|
|
@ -392,7 +397,7 @@ export function AnalyticsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Tabs */}
|
{/* Summary Tabs */}
|
||||||
<div className="mb-4 border-b border-grayScale-200">
|
<div className="mb-6 rounded-2xl border border-grayScale-100 bg-white px-5 pt-4 shadow-sm">
|
||||||
<div className="-mb-px flex gap-6">
|
<div className="-mb-px flex gap-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveSummaryTab("key")}
|
onClick={() => setActiveSummaryTab("key")}
|
||||||
|
|
@ -433,7 +438,7 @@ export function AnalyticsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
{activeSummaryTab === "key" && (
|
{activeSummaryTab === "key" && (
|
||||||
<>
|
<>
|
||||||
{/* ─── Key Metrics ─── */}
|
{/* ─── Key Metrics ─── */}
|
||||||
|
|
|
||||||
|
|
@ -167,18 +167,18 @@ function createEmptyQuestion(id: string): Question {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddNewPracticePage() {
|
export function AddNewPracticePage() {
|
||||||
const { categoryId, courseId, subCourseId } = useParams()
|
const { categoryId, courseId, subModuleId } = useParams()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const searchParams = new URLSearchParams(location.search)
|
const searchParams = new URLSearchParams(location.search)
|
||||||
const source = searchParams.get("source")
|
const source = searchParams.get("source")
|
||||||
const backTo = useMemo(() => {
|
const backTo = useMemo(() => {
|
||||||
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
|
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
|
||||||
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subCourseId}`
|
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
|
||||||
}
|
}
|
||||||
if (source === "human-language") return "/content/human-language"
|
if (source === "human-language") return "/content/human-language"
|
||||||
return `/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`
|
return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`
|
||||||
}, [location.pathname, source, categoryId, courseId, subCourseId])
|
}, [location.pathname, source, categoryId, courseId, subModuleId])
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState<Step>(1)
|
const [currentStep, setCurrentStep] = useState<Step>(1)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
@ -311,8 +311,8 @@ export function AddNewPracticePage() {
|
||||||
const setRes = await createQuestionSet({
|
const setRes = await createQuestionSet({
|
||||||
title: practiceTitle || "Untitled Practice",
|
title: practiceTitle || "Untitled Practice",
|
||||||
set_type: "PRACTICE",
|
set_type: "PRACTICE",
|
||||||
owner_type: "SUB_COURSE",
|
owner_type: "SUB_MODULE",
|
||||||
owner_id: Number(subCourseId),
|
owner_id: Number(subModuleId),
|
||||||
...(practiceDescription.trim() ? { description: practiceDescription.trim() } : {}),
|
...(practiceDescription.trim() ? { description: practiceDescription.trim() } : {}),
|
||||||
...(persona?.name ? { persona: persona.name } : {}),
|
...(persona?.name ? { persona: persona.name } : {}),
|
||||||
shuffle_questions: shuffleQuestions,
|
shuffle_questions: shuffleQuestions,
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,7 @@ export function AllCoursesPage() {
|
||||||
className="group cursor-pointer"
|
className="group cursor-pointer"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(
|
navigate(
|
||||||
`/content/category/${course.category_id}/courses/${course.id}/sub-courses`,
|
`/content/category/${course.category_id}/courses/${course.id}/sub-modules`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -393,7 +393,7 @@ export function AllCoursesPage() {
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
navigate(
|
navigate(
|
||||||
`/content/category/${course.category_id}/courses/${course.id}/sub-courses`,
|
`/content/category/${course.category_id}/courses/${course.id}/sub-modules`,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,10 @@ import {
|
||||||
getCoursesByCategory,
|
getCoursesByCategory,
|
||||||
getLearningPath,
|
getLearningPath,
|
||||||
getQuestionSetsByOwner,
|
getQuestionSetsByOwner,
|
||||||
getSubCourseEntryAssessment,
|
getSubModuleEntryAssessment,
|
||||||
reorderCategories,
|
reorderCategories,
|
||||||
reorderCourses,
|
reorderCourses,
|
||||||
reorderSubCourses,
|
reorderSubModules,
|
||||||
reorderVideos,
|
reorderVideos,
|
||||||
reorderPractices,
|
reorderPractices,
|
||||||
} from "../../api/courses.api"
|
} from "../../api/courses.api"
|
||||||
|
|
@ -320,7 +320,7 @@ export function CourseFlowBuilderPage() {
|
||||||
try {
|
try {
|
||||||
const [setsRes, entryRes] = await Promise.allSettled([
|
const [setsRes, entryRes] = await Promise.allSettled([
|
||||||
getQuestionSetsByOwner("SUB_COURSE", subCourseId),
|
getQuestionSetsByOwner("SUB_COURSE", subCourseId),
|
||||||
getSubCourseEntryAssessment(subCourseId),
|
getSubModuleEntryAssessment(subCourseId),
|
||||||
])
|
])
|
||||||
|
|
||||||
// No practice sets is a valid empty-state scenario; do not toast for 404/empty.
|
// No practice sets is a valid empty-state scenario; do not toast for 404/empty.
|
||||||
|
|
@ -429,12 +429,12 @@ export function CourseFlowBuilderPage() {
|
||||||
}))
|
}))
|
||||||
const previous = items
|
const previous = items
|
||||||
setLearningPath((prev) => (prev ? { ...prev, sub_courses: reordered } : prev))
|
setLearningPath((prev) => (prev ? { ...prev, sub_courses: reordered } : prev))
|
||||||
setSavingKey("sub-courses")
|
setSavingKey("sub-modules")
|
||||||
try {
|
try {
|
||||||
await reorderSubCourses(toReorderItems(reordered))
|
await reorderSubModules(toReorderItems(reordered))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setLearningPath((prev) => (prev ? { ...prev, sub_courses: previous } : prev))
|
setLearningPath((prev) => (prev ? { ...prev, sub_courses: previous } : prev))
|
||||||
toast.error(err?.response?.data?.message || "Failed to reorder sub-courses.")
|
toast.error(err?.response?.data?.message || "Failed to reorder sub-modules.")
|
||||||
} finally {
|
} finally {
|
||||||
setSavingKey(null)
|
setSavingKey(null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@ export function CoursesPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCourseClick = (courseId: number) => {
|
const handleCourseClick = (courseId: number) => {
|
||||||
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses`)
|
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-modules`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleViewRatings = async (courseId: number) => {
|
const handleViewRatings = async (courseId: number) => {
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ import {
|
||||||
createHumanLanguageLesson,
|
createHumanLanguageLesson,
|
||||||
deleteQuestionSet,
|
deleteQuestionSet,
|
||||||
deleteQuestion,
|
deleteQuestion,
|
||||||
deleteSubCourse,
|
deleteSubModule,
|
||||||
getHumanLanguageHierarchy,
|
getHumanLanguageHierarchy,
|
||||||
getQuestionById,
|
getQuestionById,
|
||||||
getPracticeQuestions,
|
getPracticeQuestions,
|
||||||
|
|
@ -569,7 +569,7 @@ export function HumanLanguagePage() {
|
||||||
setDeletingKey(key)
|
setDeletingKey(key)
|
||||||
try {
|
try {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
await deleteSubCourse(id)
|
await deleteSubModule(id)
|
||||||
}
|
}
|
||||||
toast.success(successMessage)
|
toast.success(successMessage)
|
||||||
await loadHierarchy()
|
await loadHierarchy()
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ import { Badge } from "../../components/ui/badge"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import {
|
import {
|
||||||
getSubCoursesByCourse,
|
getSubModulesByCourse,
|
||||||
getQuestionSetsByOwner,
|
getQuestionSetsByOwner,
|
||||||
getVideosBySubCourse,
|
getVideosBySubModule,
|
||||||
updatePractice,
|
updatePractice,
|
||||||
deleteQuestionSet,
|
deleteQuestionSet,
|
||||||
createCourseVideo,
|
createCourseVideo,
|
||||||
|
|
@ -34,10 +34,10 @@ type StatusFilter = "all" | "published" | "draft" | "archived"
|
||||||
|
|
||||||
/** Human Language–only sub-module editor: lesson (videos) + practice tabs; not used by general course flows. */
|
/** Human Language–only sub-module editor: lesson (videos) + practice tabs; not used by general course flows. */
|
||||||
export function HumanLanguageSubModulePage() {
|
export function HumanLanguageSubModulePage() {
|
||||||
const { categoryId, courseId, subCourseId } = useParams<{
|
const { categoryId, courseId, subModuleId } = useParams<{
|
||||||
categoryId: string
|
categoryId: string
|
||||||
courseId: string
|
courseId: string
|
||||||
subCourseId: string
|
subModuleId: string
|
||||||
}>()
|
}>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
|
@ -92,12 +92,12 @@ export function HumanLanguageSubModulePage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (!subCourseId || !courseId) return
|
if (!subModuleId || !courseId) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subCoursesRes = await getSubCoursesByCourse(Number(courseId))
|
const subCoursesRes = await getSubModulesByCourse(Number(courseId))
|
||||||
const foundSubCourse = subCoursesRes.data.data.sub_courses?.find(
|
const foundSubCourse = subCoursesRes.data.data.sub_courses?.find(
|
||||||
(sc) => sc.id === Number(subCourseId)
|
(sc) => sc.id === Number(subModuleId)
|
||||||
)
|
)
|
||||||
setSubCourse(foundSubCourse ?? null)
|
setSubCourse(foundSubCourse ?? null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -109,13 +109,13 @@ export function HumanLanguageSubModulePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchData()
|
fetchData()
|
||||||
}, [subCourseId, courseId])
|
}, [subModuleId, courseId])
|
||||||
|
|
||||||
const fetchPractices = async () => {
|
const fetchPractices = async () => {
|
||||||
if (!subCourseId) return
|
if (!subModuleId) return
|
||||||
setPracticesLoading(true)
|
setPracticesLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getQuestionSetsByOwner("SUB_COURSE", Number(subCourseId))
|
const res = await getQuestionSetsByOwner("SUB_MODULE", Number(subModuleId))
|
||||||
const raw = res.data.data
|
const raw = res.data.data
|
||||||
const list = Array.isArray(raw) ? raw : raw?.question_sets ?? []
|
const list = Array.isArray(raw) ? raw : raw?.question_sets ?? []
|
||||||
setPractices(list)
|
setPractices(list)
|
||||||
|
|
@ -127,10 +127,10 @@ export function HumanLanguageSubModulePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchVideos = async () => {
|
const fetchVideos = async () => {
|
||||||
if (!subCourseId) return
|
if (!subModuleId) return
|
||||||
setVideosLoading(true)
|
setVideosLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getVideosBySubCourse(Number(subCourseId))
|
const res = await getVideosBySubModule(Number(subModuleId))
|
||||||
setVideos(res.data.data.videos ?? [])
|
setVideos(res.data.data.videos ?? [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch videos:", err)
|
console.error("Failed to fetch videos:", err)
|
||||||
|
|
@ -145,10 +145,10 @@ export function HumanLanguageSubModulePage() {
|
||||||
} else if (activeTab === "lesson") {
|
} else if (activeTab === "lesson") {
|
||||||
fetchVideos()
|
fetchVideos()
|
||||||
}
|
}
|
||||||
}, [activeTab, subCourseId])
|
}, [activeTab, subModuleId])
|
||||||
|
|
||||||
const handleAddPractice = () => {
|
const handleAddPractice = () => {
|
||||||
navigate(`/content/human-language/${categoryId}/${courseId}/sub-module/${subCourseId}/add-practice`)
|
navigate(`/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}/add-practice`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -207,7 +207,7 @@ export function HumanLanguageSubModulePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePracticeClick = (practiceId: number) => {
|
const handlePracticeClick = (practiceId: number) => {
|
||||||
navigate(`/content/human-language/${categoryId}/${courseId}/sub-module/${subCourseId}/practices/${practiceId}/questions`)
|
navigate(`/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}/practices/${practiceId}/questions`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddVideo = () => {
|
const handleAddVideo = () => {
|
||||||
|
|
@ -248,7 +248,7 @@ export function HumanLanguageSubModulePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveNewVideo = async () => {
|
const handleSaveNewVideo = async () => {
|
||||||
if (!subCourseId || !videoFile) return
|
if (!subModuleId || !videoFile) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
try {
|
try {
|
||||||
|
|
@ -270,7 +270,7 @@ export function HumanLanguageSubModulePage() {
|
||||||
const finalTitle = videoTitle.trim() || videoFile.name
|
const finalTitle = videoTitle.trim() || videoFile.name
|
||||||
|
|
||||||
await createCourseVideo({
|
await createCourseVideo({
|
||||||
sub_course_id: Number(subCourseId),
|
sub_module_id: Number(subModuleId),
|
||||||
title: finalTitle,
|
title: finalTitle,
|
||||||
description: videoDescription.trim(),
|
description: videoDescription.trim(),
|
||||||
video_url: finalVideoUrl,
|
video_url: finalVideoUrl,
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ const typeColors: Record<QuestionType, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PracticeQuestionsPage() {
|
export function PracticeQuestionsPage() {
|
||||||
const { categoryId, courseId, subCourseId, practiceId } = useParams()
|
const { categoryId, courseId, subModuleId, practiceId } = useParams()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
const [questions, setQuestions] = useState<PracticeQuestion[]>([])
|
const [questions, setQuestions] = useState<PracticeQuestion[]>([])
|
||||||
|
|
@ -103,10 +103,10 @@ export function PracticeQuestionsPage() {
|
||||||
|
|
||||||
const backLink = useMemo(() => {
|
const backLink = useMemo(() => {
|
||||||
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
|
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
|
||||||
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subCourseId}`
|
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
|
||||||
}
|
}
|
||||||
return `/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`
|
return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`
|
||||||
}, [location.pathname, categoryId, courseId, subCourseId])
|
}, [location.pathname, categoryId, courseId, subModuleId])
|
||||||
|
|
||||||
const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => {
|
const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => {
|
||||||
if (type === "TRUE_FALSE") {
|
if (type === "TRUE_FALSE") {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
getCourseCategories,
|
getCourseCategories,
|
||||||
getCoursesByCategory,
|
getCoursesByCategory,
|
||||||
getQuestionById,
|
getQuestionById,
|
||||||
getSubCoursesByCourse,
|
getSubModulesByCourse,
|
||||||
createQuestion,
|
createQuestion,
|
||||||
createQuestionSet,
|
createQuestionSet,
|
||||||
// getQuestions,
|
// getQuestions,
|
||||||
|
|
@ -441,7 +441,7 @@ export function SpeakingPage() {
|
||||||
|
|
||||||
const subCourseResponses = await Promise.all(
|
const subCourseResponses = await Promise.all(
|
||||||
courseRecords.map(async ({ category, course }) => {
|
courseRecords.map(async ({ category, course }) => {
|
||||||
const res = await getSubCoursesByCourse(course.id)
|
const res = await getSubModulesByCourse(course.id)
|
||||||
const subCourses = res.data?.data?.sub_courses ?? []
|
const subCourses = res.data?.data?.sub_courses ?? []
|
||||||
return subCourses.map((subCourse: SubCourse) => ({
|
return subCourses.map((subCourse: SubCourse) => ({
|
||||||
id: subCourse.id,
|
id: subCourse.id,
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ import { Badge } from "../../components/ui/badge"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import {
|
import {
|
||||||
getSubCoursesByCourse,
|
getSubModulesByCourse,
|
||||||
getQuestionSetsByOwner,
|
getQuestionSetsByOwner,
|
||||||
getVideosBySubCourse,
|
getVideosBySubModule,
|
||||||
updatePractice,
|
updatePractice,
|
||||||
deleteQuestionSet,
|
deleteQuestionSet,
|
||||||
createCourseVideo,
|
createCourseVideo,
|
||||||
|
|
@ -34,11 +34,11 @@ import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
type TabType = "video" | "practice" | "ratings"
|
type TabType = "video" | "practice" | "ratings"
|
||||||
type StatusFilter = "all" | "published" | "draft" | "archived"
|
type StatusFilter = "all" | "published" | "draft" | "archived"
|
||||||
|
|
||||||
export function SubCourseContentPage() {
|
export function SubModuleContentPage() {
|
||||||
const { categoryId, courseId, subCourseId } = useParams<{
|
const { categoryId, courseId, subModuleId } = useParams<{
|
||||||
categoryId: string
|
categoryId: string
|
||||||
courseId: string
|
courseId: string
|
||||||
subCourseId: string
|
subModuleId: string
|
||||||
}>()
|
}>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
|
@ -99,12 +99,12 @@ export function SubCourseContentPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (!subCourseId || !courseId) return
|
if (!subModuleId || !courseId) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subCoursesRes = await getSubCoursesByCourse(Number(courseId))
|
const subCoursesRes = await getSubModulesByCourse(Number(courseId))
|
||||||
const foundSubCourse = subCoursesRes.data.data.sub_courses?.find(
|
const foundSubCourse = subCoursesRes.data.data.sub_courses?.find(
|
||||||
(sc) => sc.id === Number(subCourseId)
|
(sc) => sc.id === Number(subModuleId)
|
||||||
)
|
)
|
||||||
setSubCourse(foundSubCourse ?? null)
|
setSubCourse(foundSubCourse ?? null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -116,13 +116,13 @@ export function SubCourseContentPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchData()
|
fetchData()
|
||||||
}, [subCourseId, courseId])
|
}, [subModuleId, courseId])
|
||||||
|
|
||||||
const fetchPractices = async () => {
|
const fetchPractices = async () => {
|
||||||
if (!subCourseId) return
|
if (!subModuleId) return
|
||||||
setPracticesLoading(true)
|
setPracticesLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getQuestionSetsByOwner("SUB_COURSE", Number(subCourseId))
|
const res = await getQuestionSetsByOwner("SUB_MODULE", Number(subModuleId))
|
||||||
setPractices(res.data.data ?? [])
|
setPractices(res.data.data ?? [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch practices:", err)
|
console.error("Failed to fetch practices:", err)
|
||||||
|
|
@ -132,10 +132,10 @@ export function SubCourseContentPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchVideos = async () => {
|
const fetchVideos = async () => {
|
||||||
if (!subCourseId) return
|
if (!subModuleId) return
|
||||||
setVideosLoading(true)
|
setVideosLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getVideosBySubCourse(Number(subCourseId))
|
const res = await getVideosBySubModule(Number(subModuleId))
|
||||||
setVideos(res.data.data.videos ?? [])
|
setVideos(res.data.data.videos ?? [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch videos:", err)
|
console.error("Failed to fetch videos:", err)
|
||||||
|
|
@ -145,12 +145,12 @@ export function SubCourseContentPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchRatings = async (offset = 0) => {
|
const fetchRatings = async (offset = 0) => {
|
||||||
if (!subCourseId) return
|
if (!subModuleId) return
|
||||||
setRatingsLoading(true)
|
setRatingsLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getRatings({
|
const res = await getRatings({
|
||||||
target_type: "sub_course",
|
target_type: "sub_course",
|
||||||
target_id: Number(subCourseId),
|
target_id: Number(subModuleId),
|
||||||
limit: ratingsPageSize,
|
limit: ratingsPageSize,
|
||||||
offset,
|
offset,
|
||||||
})
|
})
|
||||||
|
|
@ -170,7 +170,7 @@ export function SubCourseContentPage() {
|
||||||
} else if (activeTab === "ratings") {
|
} else if (activeTab === "ratings") {
|
||||||
fetchRatings(ratingsPage * ratingsPageSize)
|
fetchRatings(ratingsPage * ratingsPageSize)
|
||||||
}
|
}
|
||||||
}, [activeTab, subCourseId])
|
}, [activeTab, subModuleId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === "ratings") {
|
if (activeTab === "ratings") {
|
||||||
|
|
@ -179,7 +179,7 @@ export function SubCourseContentPage() {
|
||||||
}, [ratingsPage])
|
}, [ratingsPage])
|
||||||
|
|
||||||
const handleAddPractice = () => {
|
const handleAddPractice = () => {
|
||||||
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}/add-practice`)
|
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}/add-practice`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -238,7 +238,7 @@ export function SubCourseContentPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePracticeClick = (practiceId: number) => {
|
const handlePracticeClick = (practiceId: number) => {
|
||||||
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}/practices/${practiceId}/questions`)
|
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}/practices/${practiceId}/questions`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddVideo = () => {
|
const handleAddVideo = () => {
|
||||||
|
|
@ -279,7 +279,7 @@ export function SubCourseContentPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveNewVideo = async () => {
|
const handleSaveNewVideo = async () => {
|
||||||
if (!subCourseId || !videoFile) return
|
if (!subModuleId || !videoFile) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
try {
|
try {
|
||||||
|
|
@ -301,7 +301,7 @@ export function SubCourseContentPage() {
|
||||||
const finalTitle = videoTitle.trim() || videoFile.name
|
const finalTitle = videoTitle.trim() || videoFile.name
|
||||||
|
|
||||||
await createCourseVideo({
|
await createCourseVideo({
|
||||||
sub_course_id: Number(subCourseId),
|
sub_module_id: Number(subModuleId),
|
||||||
title: finalTitle,
|
title: finalTitle,
|
||||||
description: videoDescription.trim(),
|
description: videoDescription.trim(),
|
||||||
video_url: finalVideoUrl,
|
video_url: finalVideoUrl,
|
||||||
|
|
@ -444,7 +444,7 @@ export function SubCourseContentPage() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
<Link
|
<Link
|
||||||
to={`/content/category/${categoryId}/courses/${courseId}/sub-courses`}
|
to={`/content/category/${categoryId}/courses/${courseId}/sub-modules`}
|
||||||
className="group inline-flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium text-grayScale-500 transition-all hover:bg-grayScale-50 hover:text-grayScale-900"
|
className="group inline-flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium text-grayScale-500 transition-all hover:bg-grayScale-50 hover:text-grayScale-900"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,16 @@ import alertSrc from "../../assets/Alert.svg";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
getSubCoursesByCourse,
|
getSubModulesByCourse,
|
||||||
getCoursesByCategory,
|
getCoursesByCategory,
|
||||||
getCourseCategories,
|
getCourseCategories,
|
||||||
createSubCourse,
|
createSubModule,
|
||||||
updateSubCourse,
|
updateSubModule,
|
||||||
updateSubCourseStatus,
|
updateSubModuleStatus,
|
||||||
deleteSubCourse,
|
deleteSubModule,
|
||||||
getSubCoursePrerequisites,
|
getSubModulePrerequisites,
|
||||||
addSubCoursePrerequisite,
|
addSubModulePrerequisite,
|
||||||
removeSubCoursePrerequisite,
|
removeSubModulePrerequisite,
|
||||||
} from "../../api/courses.api";
|
} from "../../api/courses.api";
|
||||||
import { uploadImageFile } from "../../api/files.api";
|
import { uploadImageFile } from "../../api/files.api";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
|
|
@ -47,7 +47,7 @@ import type {
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon";
|
import { SpinnerIcon } from "../../components/ui/spinner-icon";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export function SubCoursesPage() {
|
export function SubModulesPage() {
|
||||||
const { categoryId, courseId } = useParams<{
|
const { categoryId, courseId } = useParams<{
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
courseId: string;
|
courseId: string;
|
||||||
|
|
@ -122,10 +122,10 @@ export function SubCoursesPage() {
|
||||||
if (!courseId) return;
|
if (!courseId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subCoursesRes = await getSubCoursesByCourse(Number(courseId));
|
const subCoursesRes = await getSubModulesByCourse(Number(courseId));
|
||||||
setSubCourses(subCoursesRes.data.data.sub_courses ?? []);
|
setSubCourses(subCoursesRes.data.data.sub_courses ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch sub-courses:", err);
|
console.error("Failed to fetch sub-modules:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -135,7 +135,7 @@ export function SubCoursesPage() {
|
||||||
try {
|
try {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
scs.map((sc) =>
|
scs.map((sc) =>
|
||||||
getSubCoursePrerequisites(sc.id).then((res) => ({
|
getSubModulePrerequisites(sc.id).then((res) => ({
|
||||||
id: sc.id,
|
id: sc.id,
|
||||||
data: res.data.data ?? [],
|
data: res.data.data ?? [],
|
||||||
})),
|
})),
|
||||||
|
|
@ -159,7 +159,7 @@ export function SubCoursesPage() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [subCoursesRes, coursesRes, categoriesRes] = await Promise.all([
|
const [subCoursesRes, coursesRes, categoriesRes] = await Promise.all([
|
||||||
getSubCoursesByCourse(Number(courseId)),
|
getSubModulesByCourse(Number(courseId)),
|
||||||
getCoursesByCategory(Number(categoryId)),
|
getCoursesByCategory(Number(categoryId)),
|
||||||
getCourseCategories(),
|
getCourseCategories(),
|
||||||
]);
|
]);
|
||||||
|
|
@ -176,7 +176,7 @@ export function SubCoursesPage() {
|
||||||
);
|
);
|
||||||
setCategory(foundCategory ?? null);
|
setCategory(foundCategory ?? null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch sub-courses:", err);
|
console.error("Failed to fetch sub-modules:", err);
|
||||||
setError("Failed to load courses");
|
setError("Failed to load courses");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -195,7 +195,7 @@ export function SubCoursesPage() {
|
||||||
const handleToggleStatus = async (subCourse: SubCourse) => {
|
const handleToggleStatus = async (subCourse: SubCourse) => {
|
||||||
setTogglingId(subCourse.id);
|
setTogglingId(subCourse.id);
|
||||||
try {
|
try {
|
||||||
await updateSubCourseStatus(subCourse.id, {
|
await updateSubModuleStatus(subCourse.id, {
|
||||||
is_active: !subCourse.is_active,
|
is_active: !subCourse.is_active,
|
||||||
level: subCourse.level,
|
level: subCourse.level,
|
||||||
title: subCourse.title,
|
title: subCourse.title,
|
||||||
|
|
@ -218,7 +218,7 @@ export function SubCoursesPage() {
|
||||||
|
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
await deleteSubCourse(subCourseToDelete.id);
|
await deleteSubModule(subCourseToDelete.id);
|
||||||
setShowDeleteModal(false);
|
setShowDeleteModal(false);
|
||||||
setSubCourseToDelete(null);
|
setSubCourseToDelete(null);
|
||||||
await fetchSubCourses();
|
await fetchSubCourses();
|
||||||
|
|
@ -264,7 +264,7 @@ export function SubCoursesPage() {
|
||||||
? parsedOrder
|
? parsedOrder
|
||||||
: nextSubCourseDisplayOrder();
|
: nextSubCourseDisplayOrder();
|
||||||
|
|
||||||
await createSubCourse({
|
await createSubModule({
|
||||||
course_id: Number(courseId),
|
course_id: Number(courseId),
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
|
|
@ -309,7 +309,7 @@ export function SubCoursesPage() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
await updateSubCourse(subCourseToEdit.id, {
|
await updateSubModule(subCourseToEdit.id, {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
level,
|
level,
|
||||||
|
|
@ -328,9 +328,9 @@ export function SubCoursesPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubCourseClick = (subCourseId: number) => {
|
const handleSubModuleClick = (subModuleId: number) => {
|
||||||
navigate(
|
navigate(
|
||||||
`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`,
|
`/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -340,7 +340,7 @@ export function SubCoursesPage() {
|
||||||
setPrereqLoading(true);
|
setPrereqLoading(true);
|
||||||
setSelectedPrereqId(0);
|
setSelectedPrereqId(0);
|
||||||
try {
|
try {
|
||||||
const res = await getSubCoursePrerequisites(subCourse.id);
|
const res = await getSubModulePrerequisites(subCourse.id);
|
||||||
setPrerequisites(res.data.data ?? []);
|
setPrerequisites(res.data.data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch prerequisites:", err);
|
console.error("Failed to fetch prerequisites:", err);
|
||||||
|
|
@ -354,10 +354,10 @@ export function SubCoursesPage() {
|
||||||
if (!prereqSubCourse || !selectedPrereqId) return;
|
if (!prereqSubCourse || !selectedPrereqId) return;
|
||||||
setPrereqAdding(true);
|
setPrereqAdding(true);
|
||||||
try {
|
try {
|
||||||
await addSubCoursePrerequisite(prereqSubCourse.id, {
|
await addSubModulePrerequisite(prereqSubCourse.id, {
|
||||||
prerequisite_sub_course_id: selectedPrereqId,
|
prerequisite_sub_course_id: selectedPrereqId,
|
||||||
});
|
});
|
||||||
const res = await getSubCoursePrerequisites(prereqSubCourse.id);
|
const res = await getSubModulePrerequisites(prereqSubCourse.id);
|
||||||
setPrerequisites(res.data.data ?? []);
|
setPrerequisites(res.data.data ?? []);
|
||||||
setSelectedPrereqId(0);
|
setSelectedPrereqId(0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -371,8 +371,8 @@ export function SubCoursesPage() {
|
||||||
if (!prereqSubCourse) return;
|
if (!prereqSubCourse) return;
|
||||||
setPrereqRemoving(prereqId);
|
setPrereqRemoving(prereqId);
|
||||||
try {
|
try {
|
||||||
await removeSubCoursePrerequisite(prereqSubCourse.id, prereqId);
|
await removeSubModulePrerequisite(prereqSubCourse.id, prereqId);
|
||||||
const res = await getSubCoursePrerequisites(prereqSubCourse.id);
|
const res = await getSubModulePrerequisites(prereqSubCourse.id);
|
||||||
setPrerequisites(res.data.data ?? []);
|
setPrerequisites(res.data.data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to remove prerequisite:", err);
|
console.error("Failed to remove prerequisite:", err);
|
||||||
|
|
@ -385,7 +385,7 @@ export function SubCoursesPage() {
|
||||||
const flowLayers = (() => {
|
const flowLayers = (() => {
|
||||||
if (subCourses.length === 0) return [];
|
if (subCourses.length === 0) return [];
|
||||||
|
|
||||||
// Find sub-courses with no prerequisites (roots)
|
// Find sub-modules with no prerequisites (roots)
|
||||||
const hasPrereqs = new Set<number>();
|
const hasPrereqs = new Set<number>();
|
||||||
const isPrereqOf = new Map<number, number[]>(); // prereqId -> [subCourseIds that depend on it]
|
const isPrereqOf = new Map<number, number[]>(); // prereqId -> [subCourseIds that depend on it]
|
||||||
|
|
||||||
|
|
@ -557,7 +557,7 @@ export function SubCoursesPage() {
|
||||||
<Card
|
<Card
|
||||||
key={subCourse.id}
|
key={subCourse.id}
|
||||||
className="group cursor-pointer overflow-hidden border border-grayScale-100 bg-white shadow-sm transition-all duration-200 hover:shadow-soft hover:-translate-y-1 hover:border-brand-100"
|
className="group cursor-pointer overflow-hidden border border-grayScale-100 bg-white shadow-sm transition-all duration-200 hover:shadow-soft hover:-translate-y-1 hover:border-brand-100"
|
||||||
onClick={() => handleSubCourseClick(subCourse.id)}
|
onClick={() => handleSubModuleClick(subCourse.id)}
|
||||||
>
|
>
|
||||||
{/* Thumbnail with level badge */}
|
{/* Thumbnail with level badge */}
|
||||||
<div className="relative aspect-video w-full overflow-hidden">
|
<div className="relative aspect-video w-full overflow-hidden">
|
||||||
|
|
@ -738,7 +738,7 @@ export function SubCoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Sub-course Modal — POST /course-management/sub-courses */}
|
{/* Add Sub-module Modal */}
|
||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden bg-black/40 p-3 backdrop-blur-sm sm:p-6">
|
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden bg-black/40 p-3 backdrop-blur-sm sm:p-6">
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export interface GetCourseCategoriesResponse {
|
||||||
export interface Course {
|
export interface Course {
|
||||||
id: number
|
id: number
|
||||||
category_id: number
|
category_id: number
|
||||||
|
sub_category_id?: number | null
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
thumbnail: string
|
thumbnail: string
|
||||||
|
|
@ -191,9 +192,11 @@ export interface UpdateModuleStatusRequest {
|
||||||
export interface SubCourse {
|
export interface SubCourse {
|
||||||
id: number
|
id: number
|
||||||
course_id: number
|
course_id: number
|
||||||
|
module_id?: number
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
level: string
|
level: string
|
||||||
|
cefr_level?: string
|
||||||
thumbnail: string
|
thumbnail: string
|
||||||
display_order: number
|
display_order: number
|
||||||
sub_level?: string
|
sub_level?: string
|
||||||
|
|
@ -211,7 +214,7 @@ export interface GetSubCoursesResponse {
|
||||||
metadata: unknown
|
metadata: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /course-management/sub-courses */
|
/** Compatibility request used to create sub-modules */
|
||||||
export interface CreateSubCourseRequest {
|
export interface CreateSubCourseRequest {
|
||||||
course_id: number
|
course_id: number
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -237,7 +240,8 @@ export interface UpdateSubCourseStatusRequest {
|
||||||
// SubCourse Video
|
// SubCourse Video
|
||||||
export interface SubCourseVideo {
|
export interface SubCourseVideo {
|
||||||
id: number
|
id: number
|
||||||
sub_course_id: number
|
sub_course_id?: number
|
||||||
|
sub_module_id?: number
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
video_url: string
|
video_url: string
|
||||||
|
|
@ -259,7 +263,8 @@ export interface GetSubCourseVideosResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateSubCourseVideoRequest {
|
export interface CreateSubCourseVideoRequest {
|
||||||
sub_course_id: number
|
sub_course_id?: number
|
||||||
|
sub_module_id?: number
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
video_url: string
|
video_url: string
|
||||||
|
|
@ -269,7 +274,8 @@ export type VideoVisibility = "PUBLISHED" | "DRAFT" | "PRIVATE" | "UNLISTED" | s
|
||||||
export type VideoStatus = "PUBLISHED" | "DRAFT" | "ARCHIVED" | string
|
export type VideoStatus = "PUBLISHED" | "DRAFT" | "ARCHIVED" | string
|
||||||
|
|
||||||
export interface CreateCourseVideoRequest {
|
export interface CreateCourseVideoRequest {
|
||||||
sub_course_id: number
|
sub_course_id?: number
|
||||||
|
sub_module_id?: number
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
video_url: string
|
video_url: string
|
||||||
|
|
@ -281,7 +287,8 @@ export interface CreateCourseVideoRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateVimeoVideoRequest {
|
export interface CreateVimeoVideoRequest {
|
||||||
sub_course_id: number
|
sub_course_id?: number
|
||||||
|
sub_module_id?: number
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
source_url: string
|
source_url: string
|
||||||
|
|
@ -298,9 +305,13 @@ export interface UpdateSubCourseVideoRequest {
|
||||||
// Practice now belongs to SubCourse
|
// Practice now belongs to SubCourse
|
||||||
export interface Practice {
|
export interface Practice {
|
||||||
id: number
|
id: number
|
||||||
sub_course_id: number
|
sub_course_id?: number
|
||||||
|
sub_module_id?: number
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
|
thumbnail?: string
|
||||||
|
intro_video_url?: string
|
||||||
|
question_set_id?: number
|
||||||
banner_image: string
|
banner_image: string
|
||||||
persona: string
|
persona: string
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
|
|
@ -318,9 +329,12 @@ export interface GetPracticesResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePracticeRequest {
|
export interface CreatePracticeRequest {
|
||||||
sub_course_id: number
|
sub_course_id?: number
|
||||||
|
sub_module_id?: number
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
|
thumbnail?: string
|
||||||
|
intro_video_url?: string
|
||||||
persona?: string
|
persona?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -390,7 +404,7 @@ export interface UpdatePracticeQuestionRequest {
|
||||||
// Question Sets (Practice sets fetched via /question-sets)
|
// Question Sets (Practice sets fetched via /question-sets)
|
||||||
export type QuestionSetType = "PRACTICE" | "EXAM"
|
export type QuestionSetType = "PRACTICE" | "EXAM"
|
||||||
export type QuestionSetStatus = "PUBLISHED" | "DRAFT" | "ARCHIVED"
|
export type QuestionSetStatus = "PUBLISHED" | "DRAFT" | "ARCHIVED"
|
||||||
export type QuestionSetOwnerType = "SUB_COURSE" | "COURSE"
|
export type QuestionSetOwnerType = "SUB_COURSE" | "SUB_MODULE" | "COURSE"
|
||||||
|
|
||||||
export interface QuestionSet {
|
export interface QuestionSet {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -415,7 +429,7 @@ export interface GetQuestionSetsResponse {
|
||||||
|
|
||||||
export interface GetQuestionSetsParams {
|
export interface GetQuestionSetsParams {
|
||||||
set_type?: "PRACTICE" | "INITIAL_ASSESSMENT" | "EXAM" | string
|
set_type?: "PRACTICE" | "INITIAL_ASSESSMENT" | "EXAM" | string
|
||||||
owner_type?: "SUB_COURSE" | "COURSE" | string
|
owner_type?: "SUB_COURSE" | "SUB_MODULE" | "COURSE" | string
|
||||||
owner_id?: number
|
owner_id?: number
|
||||||
status?: "DRAFT" | "PUBLISHED" | "ARCHIVED" | string
|
status?: "DRAFT" | "PUBLISHED" | "ARCHIVED" | string
|
||||||
limit?: number
|
limit?: number
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user