Compare commits

..

No commits in common. "588b238b497cc89748d3f3f753bc94d80f468415" and "26e1b0a7d54e7142c420c3101e1a36d23c77672e" have entirely different histories.

16 changed files with 194 additions and 641 deletions

View File

@ -1,4 +1,4 @@
# Yimaru Academy LMS Admin Panel # Yimaru Academy LMS Admin Dashboard
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.

View File

@ -58,77 +58,14 @@ 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("/course-management/hierarchy").then((res) => { http.get<GetCourseCategoriesResponse>("/course-management/categories")
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) =>
data.parent_id http.post("/course-management/categories", data)
? 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("/course-management/hierarchy").then((res) => { http.get<GetCoursesResponse>(`/course-management/categories/${categoryId}/courses`)
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)
@ -147,143 +84,59 @@ 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)
// Sub-Module APIs (Unified Hierarchy) // SubCourse APIs (New Hierarchy)
export const getSubModulesByCourse = (courseId: number) => export const getSubCoursesByCourse = (courseId: number) =>
http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => { http.get<GetSubCoursesResponse>(`/course-management/courses/${courseId}/sub-courses`)
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 createSubModule = (data: CreateSubCourseRequest) => export const createSubCourse = (data: CreateSubCourseRequest) =>
http http.post("/course-management/sub-courses", data)
.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 updateSubModuleThumbnail = (subModuleId: number, thumbnailUrl: string) => export const updateSubCourseThumbnail = (subCourseId: number, thumbnailUrl: string) =>
http.post(`/course-management/sub-courses/${subModuleId}/thumbnail`, { http.post(`/course-management/sub-courses/${subCourseId}/thumbnail`, {
thumbnail_url: thumbnailUrl, thumbnail_url: thumbnailUrl,
}) })
export const updateSubModule = (subModuleId: number, data: UpdateSubCourseRequest) => export const updateSubCourse = (subCourseId: number, data: UpdateSubCourseRequest) =>
http.put(`/course-management/sub-modules/${subModuleId}`, data) http.patch(`/course-management/sub-courses/${subCourseId}`, data)
export const updateSubModuleStatus = (subModuleId: number, data: UpdateSubCourseStatusRequest) => export const updateSubCourseStatus = (subCourseId: number, data: UpdateSubCourseStatusRequest) =>
http.put(`/course-management/sub-modules/${subModuleId}`, data) http.patch(`/course-management/sub-courses/${subCourseId}`, data)
export const deleteSubModule = (subModuleId: number) => export const deleteSubCourse = (subCourseId: number) =>
http.delete(`/course-management/sub-modules/${subModuleId}`) http.delete(`/course-management/sub-courses/${subCourseId}`)
// Sub-Module Video APIs // SubCourse Video APIs
export const getVideosBySubModule = (subModuleId: number) => export const getVideosBySubCourse = (subCourseId: number) =>
http.get<GetSubCourseVideosResponse>(`/course-management/sub-modules/${subModuleId}/videos`) http.get<GetSubCourseVideosResponse>(`/course-management/sub-courses/${subCourseId}/videos`)
export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) => export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) =>
http.post("/course-management/sub-module-videos", { http.post("/course-management/sub-course-videos", data)
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/sub-module-videos", { http.post("/course-management/videos", data)
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-module-videos/${videoId}`, data) http.put(`/course-management/sub-course-videos/${videoId}`, data)
export const deleteSubCourseVideo = (videoId: number) => export const deleteSubCourseVideo = (videoId: number) =>
http.delete(`/course-management/sub-module-videos/${videoId}`) http.delete(`/course-management/sub-course-videos/${videoId}`)
// Practice APIs - for Sub-Module practices (Unified Hierarchy) // Practice APIs - for SubCourse practices (New Hierarchy)
export const getPracticesBySubModule = (subModuleId: number) => // Practices are question sets: POST /question-sets with set_type: "PRACTICE", owner_type: "SUB_COURSE".
export const getPracticesBySubCourse = (subCourseId: number) =>
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", { http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
params: { owner_type: "SUB_MODULE", owner_id: subModuleId }, params: { owner_type: "SUB_COURSE", owner_id: subCourseId },
}) })
export const createPractice = (data: CreatePracticeRequest) => export const createPractice = (data: CreatePracticeRequest) =>
http http.post<CreateQuestionSetResponse>("/question-sets", {
.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) =>
http.put(`/course-management/practices/${practiceId}`, data) http.put(`/course-management/practices/${practiceId}`, data)
@ -307,43 +160,13 @@ export const getPracticeQuestionsByPractice = (
}) })
export const createPracticeQuestion = (data: CreatePracticeQuestionRequest) => export const createPracticeQuestion = (data: CreatePracticeQuestionRequest) =>
http http.post("/course-management/practice-questions", data)
.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(`/questions/${questionId}`, { http.put(`/course-management/practice-questions/${questionId}`, data)
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(`/questions/${questionId}`) http.delete(`/course-management/practice-questions/${questionId}`)
// ============================================ // ============================================
// Legacy APIs (deprecated - using SubCourse hierarchy now) // Legacy APIs (deprecated - using SubCourse hierarchy now)
@ -407,10 +230,7 @@ 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: { params: { owner_type: ownerType, owner_id: ownerId },
owner_type: ownerType === "SUB_COURSE" ? "SUB_MODULE" : ownerType,
owner_id: ownerId,
},
}) })
export const getQuestionSetById = (questionSetId: number) => export const getQuestionSetById = (questionSetId: number) =>
@ -456,46 +276,17 @@ 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("/vimeo/uploads/pull", { http.post("/course-management/videos/vimeo", data)
name: data.title,
description: data.description,
source_url: data.source_url,
file_size: data.file_size,
})
// Sub-module Prerequisite APIs // Sub-course Prerequisite APIs
export const getSubModulePrerequisites = (subModuleId: number) => export const getSubCoursePrerequisites = (subCourseId: number) =>
Promise.resolve({ http.get<GetSubCoursePrerequisitesResponse>(`/course-management/sub-courses/${subCourseId}/prerequisites`)
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 addSubModulePrerequisite = (subModuleId: number, data: AddSubCoursePrerequisiteRequest) => export const addSubCoursePrerequisite = (subCourseId: number, data: AddSubCoursePrerequisiteRequest) =>
Promise.resolve({ http.post(`/course-management/sub-courses/${subCourseId}/prerequisites`, data)
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 removeSubModulePrerequisite = (subModuleId: number, prerequisiteId: number) => export const removeSubCoursePrerequisite = (subCourseId: number, prerequisiteId: number) =>
Promise.resolve({ http.delete(`/course-management/sub-courses/${subCourseId}/prerequisites/${prerequisiteId}`)
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) =>
@ -507,216 +298,14 @@ export const getHumanLanguageLessonsByCourse = (courseId: number, cefr_level: st
}) })
export const getHumanLanguageHierarchy = () => export const getHumanLanguageHierarchy = () =>
http.get<GetHumanLanguageHierarchyResponse>("/course-management/hierarchy").then(async (res) => { http.get<GetHumanLanguageHierarchyResponse>("/course-management/human-language/hierarchy")
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 http.post("/course-management/human-language/lessons", data)
.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 getSubModuleEntryAssessment = (subModuleId: number) => export const getSubCourseEntryAssessment = (subCourseId: number) =>
http.get<GetSubCourseEntryAssessmentResponse>( http.get<GetSubCourseEntryAssessmentResponse>(
`/question-sets/sub-courses/${subModuleId}/entry-assessment`, `/question-sets/sub-courses/${subCourseId}/entry-assessment`,
) )
const buildReorderPayload = (items: ReorderItem[]) => { const buildReorderPayload = (items: ReorderItem[]) => {
@ -740,32 +329,20 @@ const buildReorderPayload = (items: ReorderItem[]) => {
return { items: normalized } return { items: normalized }
} }
const reorderNotYetSupported = (items: ReorderItem[]) => Promise.resolve({ data: { data: buildReorderPayload(items) } }) export const reorderCategories = (items: ReorderItem[]) =>
http.put("/course-management/categories/reorder", buildReorderPayload(items))
export const reorderCategories = reorderNotYetSupported export const reorderCourses = (items: ReorderItem[]) =>
http.put("/course-management/courses/reorder", buildReorderPayload(items))
export const reorderCourses = reorderNotYetSupported export const reorderSubCourses = (items: ReorderItem[]) =>
http.put("/course-management/sub-courses/reorder", buildReorderPayload(items))
export const reorderSubModules = reorderNotYetSupported export const reorderVideos = (items: ReorderItem[]) =>
http.put("/course-management/videos/reorder", buildReorderPayload(items))
// Backward-compatible aliases export const reorderPractices = (items: ReorderItem[]) =>
export const getSubCoursesByCourse = getSubModulesByCourse http.put("/course-management/practices/reorder", buildReorderPayload(items))
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) =>

View File

@ -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 { SubModulesPage } from "../pages/content-management/SubCoursesPage" import { SubCoursesPage } from "../pages/content-management/SubCoursesPage"
import { SubModuleContentPage } from "../pages/content-management/SubCourseContentPage" import { SubCourseContentPage } 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,29 +80,24 @@ 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/:subModuleId/add-practice" path="human-language/:categoryId/:courseId/sub-module/:subCourseId/add-practice"
element={<AddNewPracticePage />} element={<AddNewPracticePage />}
/> />
<Route <Route
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/practices/:practiceId/questions" path="human-language/:categoryId/:courseId/sub-module/:subCourseId/practices/:practiceId/questions"
element={<PracticeQuestionsPage />} element={<PracticeQuestionsPage />}
/> />
<Route <Route
path="human-language/:categoryId/:courseId/sub-module/:subModuleId" path="human-language/:categoryId/:courseId/sub-module/:subCourseId"
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-module → Lesson/Practice */} {/* Course → Sub-course → Video/Practice */}
<Route path="category/:categoryId/courses/:courseId/sub-modules" element={<SubModulesPage />} /> <Route path="category/:categoryId/courses/:courseId/sub-courses" element={<SubCoursesPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId" element={<SubModuleContentPage />} /> <Route path="category/:categoryId/courses/:courseId/sub-courses/:subCourseId" element={<SubCourseContentPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-practice" element={<AddNewPracticePage />} /> <Route path="category/:categoryId/courses/:courseId/sub-courses/:subCourseId/add-practice" element={<AddNewPracticePage />} />
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} /> <Route path="category/:categoryId/courses/:courseId/sub-courses/:subCourseId/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 />} />

View File

@ -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-modules, ${dashboard.courses.total_videos} videos`} deltaLabel={`${dashboard.courses.total_sub_courses} sub-courses, ${dashboard.courses.total_videos} videos`}
deltaPositive deltaPositive
/> />
<StatCard <StatCard

View File

@ -70,26 +70,21 @@ function KpiCard({
className?: string className?: string
}) { }) {
return ( return (
<Card <Card className={cn("shadow-none transition-shadow hover:shadow-md", className)}>
className={cn( <CardContent className="p-4">
"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-semibold uppercase tracking-wide text-grayScale-400">{label}</div> <div className="text-xs font-medium text-grayScale-500">{label}</div>
<div className="mt-1.5 text-[1.75rem] font-semibold leading-none tracking-tight text-grayScale-800">{value}</div> <div className="mt-1 text-2xl font-semibold tracking-tight">{value}</div>
</div> </div>
<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"> <div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
<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-3 flex items-center gap-1 text-xs font-medium", "mt-2 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",
@ -249,16 +244,16 @@ function Section({
const [open, setOpen] = useState(defaultOpen) const [open, setOpen] = useState(defaultOpen)
return ( return (
<div className="rounded-2xl border border-grayScale-100 bg-white shadow-sm"> <div className="rounded-xl border bg-white">
<button <button
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-3 px-6 py-4 text-left transition-colors hover:bg-grayScale-50/80" className="flex w-full items-center gap-3 px-5 py-3.5 text-left transition-colors hover:bg-grayScale-50"
> >
<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"> <div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-brand-100 text-brand-600">
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
</div> </div>
<span className="flex-1 text-sm font-semibold tracking-wide text-grayScale-800">{title}</span> <span className="flex-1 text-sm font-semibold 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}
@ -278,7 +273,7 @@ function Section({
)} )}
> >
<div className="overflow-hidden"> <div className="overflow-hidden">
<div className="border-t border-grayScale-100 px-6 pb-6 pt-4">{children}</div> <div className="px-5 pb-5 pt-1">{children}</div>
</div> </div>
</div> </div>
</div> </div>
@ -310,9 +305,9 @@ export function AnalyticsPage() {
if (loading) { if (loading) {
return ( return (
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4"> <div className="mx-auto w-full max-w-6xl">
<div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div> <div className="mb-4 text-sm font-semibold text-grayScale-500">Analytics</div>
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-grayScale-100 bg-white py-24 shadow-sm"> <div className="flex flex-col items-center justify-center gap-3 py-20">
<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>
@ -322,9 +317,9 @@ export function AnalyticsPage() {
if (error || !dashboard) { if (error || !dashboard) {
return ( return (
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4"> <div className="mx-auto w-full max-w-6xl">
<div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div> <div className="mb-4 text-sm font-semibold text-grayScale-500">Analytics</div>
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-red-100 bg-red-50/30 py-24"> <div className="flex flex-col items-center justify-center gap-3 py-20">
<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}>
@ -380,12 +375,12 @@ export function AnalyticsPage() {
}) })
return ( return (
<div className="mx-auto w-full max-w-[1280px] px-2 pb-6 sm:px-4"> <div className="mx-auto w-full max-w-6xl">
{/* Header */} {/* Header */}
<div className="mb-7 flex flex-wrap items-end justify-between gap-4"> <div className="mb-5 flex items-end justify-between">
<div> <div>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div> <div className="mb-1 text-sm font-semibold text-grayScale-500">Analytics</div>
<h1 className="text-3xl font-semibold tracking-tight text-grayScale-900">Platform Overview</h1> <h1 className="text-2xl font-semibold tracking-tight">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>
@ -397,7 +392,7 @@ export function AnalyticsPage() {
</div> </div>
{/* Summary Tabs */} {/* Summary Tabs */}
<div className="mb-6 rounded-2xl border border-grayScale-100 bg-white px-5 pt-4 shadow-sm"> <div className="mb-4 border-b border-grayScale-200">
<div className="-mb-px flex gap-6"> <div className="-mb-px flex gap-6">
<button <button
onClick={() => setActiveSummaryTab("key")} onClick={() => setActiveSummaryTab("key")}
@ -438,7 +433,7 @@ export function AnalyticsPage() {
</div> </div>
</div> </div>
<div className="space-y-5"> <div className="space-y-4">
{activeSummaryTab === "key" && ( {activeSummaryTab === "key" && (
<> <>
{/* ─── Key Metrics ─── */} {/* ─── Key Metrics ─── */}

View File

@ -167,18 +167,18 @@ function createEmptyQuestion(id: string): Question {
} }
export function AddNewPracticePage() { export function AddNewPracticePage() {
const { categoryId, courseId, subModuleId } = useParams() const { categoryId, courseId, subCourseId } = 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/${subModuleId}` return `/content/human-language/${categoryId}/${courseId}/sub-module/${subCourseId}`
} }
if (source === "human-language") return "/content/human-language" if (source === "human-language") return "/content/human-language"
return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}` return `/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`
}, [location.pathname, source, categoryId, courseId, subModuleId]) }, [location.pathname, source, categoryId, courseId, subCourseId])
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_MODULE", owner_type: "SUB_COURSE",
owner_id: Number(subModuleId), owner_id: Number(subCourseId),
...(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,

View File

@ -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-modules`, `/content/category/${course.category_id}/courses/${course.id}/sub-courses`,
) )
} }
> >
@ -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-modules`, `/content/category/${course.category_id}/courses/${course.id}/sub-courses`,
) )
}} }}
> >

View File

@ -34,10 +34,10 @@ import {
getCoursesByCategory, getCoursesByCategory,
getLearningPath, getLearningPath,
getQuestionSetsByOwner, getQuestionSetsByOwner,
getSubModuleEntryAssessment, getSubCourseEntryAssessment,
reorderCategories, reorderCategories,
reorderCourses, reorderCourses,
reorderSubModules, reorderSubCourses,
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),
getSubModuleEntryAssessment(subCourseId), getSubCourseEntryAssessment(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-modules") setSavingKey("sub-courses")
try { try {
await reorderSubModules(toReorderItems(reordered)) await reorderSubCourses(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-modules.") toast.error(err?.response?.data?.message || "Failed to reorder sub-courses.")
} finally { } finally {
setSavingKey(null) setSavingKey(null)
} }

View File

@ -245,7 +245,7 @@ export function CoursesPage() {
} }
const handleCourseClick = (courseId: number) => { const handleCourseClick = (courseId: number) => {
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-modules`) navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses`)
} }
const handleViewRatings = async (courseId: number) => { const handleViewRatings = async (courseId: number) => {

View File

@ -37,7 +37,7 @@ import {
createHumanLanguageLesson, createHumanLanguageLesson,
deleteQuestionSet, deleteQuestionSet,
deleteQuestion, deleteQuestion,
deleteSubModule, deleteSubCourse,
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 deleteSubModule(id) await deleteSubCourse(id)
} }
toast.success(successMessage) toast.success(successMessage)
await loadHierarchy() await loadHierarchy()

View File

@ -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 {
getSubModulesByCourse, getSubCoursesByCourse,
getQuestionSetsByOwner, getQuestionSetsByOwner,
getVideosBySubModule, getVideosBySubCourse,
updatePractice, updatePractice,
deleteQuestionSet, deleteQuestionSet,
createCourseVideo, createCourseVideo,
@ -34,10 +34,10 @@ type StatusFilter = "all" | "published" | "draft" | "archived"
/** Human Languageonly sub-module editor: lesson (videos) + practice tabs; not used by general course flows. */ /** Human Languageonly sub-module editor: lesson (videos) + practice tabs; not used by general course flows. */
export function HumanLanguageSubModulePage() { export function HumanLanguageSubModulePage() {
const { categoryId, courseId, subModuleId } = useParams<{ const { categoryId, courseId, subCourseId } = useParams<{
categoryId: string categoryId: string
courseId: string courseId: string
subModuleId: string subCourseId: string
}>() }>()
const navigate = useNavigate() const navigate = useNavigate()
@ -92,12 +92,12 @@ export function HumanLanguageSubModulePage() {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (!subModuleId || !courseId) return if (!subCourseId || !courseId) return
try { try {
const subCoursesRes = await getSubModulesByCourse(Number(courseId)) const subCoursesRes = await getSubCoursesByCourse(Number(courseId))
const foundSubCourse = subCoursesRes.data.data.sub_courses?.find( const foundSubCourse = subCoursesRes.data.data.sub_courses?.find(
(sc) => sc.id === Number(subModuleId) (sc) => sc.id === Number(subCourseId)
) )
setSubCourse(foundSubCourse ?? null) setSubCourse(foundSubCourse ?? null)
} catch (err) { } catch (err) {
@ -109,13 +109,13 @@ export function HumanLanguageSubModulePage() {
} }
fetchData() fetchData()
}, [subModuleId, courseId]) }, [subCourseId, courseId])
const fetchPractices = async () => { const fetchPractices = async () => {
if (!subModuleId) return if (!subCourseId) return
setPracticesLoading(true) setPracticesLoading(true)
try { try {
const res = await getQuestionSetsByOwner("SUB_MODULE", Number(subModuleId)) const res = await getQuestionSetsByOwner("SUB_COURSE", Number(subCourseId))
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 (!subModuleId) return if (!subCourseId) return
setVideosLoading(true) setVideosLoading(true)
try { try {
const res = await getVideosBySubModule(Number(subModuleId)) const res = await getVideosBySubCourse(Number(subCourseId))
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, subModuleId]) }, [activeTab, subCourseId])
const handleAddPractice = () => { const handleAddPractice = () => {
navigate(`/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}/add-practice`) navigate(`/content/human-language/${categoryId}/${courseId}/sub-module/${subCourseId}/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/${subModuleId}/practices/${practiceId}/questions`) navigate(`/content/human-language/${categoryId}/${courseId}/sub-module/${subCourseId}/practices/${practiceId}/questions`)
} }
const handleAddVideo = () => { const handleAddVideo = () => {
@ -248,7 +248,7 @@ export function HumanLanguageSubModulePage() {
} }
const handleSaveNewVideo = async () => { const handleSaveNewVideo = async () => {
if (!subModuleId || !videoFile) return if (!subCourseId || !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_module_id: Number(subModuleId), sub_course_id: Number(subCourseId),
title: finalTitle, title: finalTitle,
description: videoDescription.trim(), description: videoDescription.trim(),
video_url: finalVideoUrl, video_url: finalVideoUrl,

View File

@ -58,7 +58,7 @@ const typeColors: Record<QuestionType, string> = {
} }
export function PracticeQuestionsPage() { export function PracticeQuestionsPage() {
const { categoryId, courseId, subModuleId, practiceId } = useParams() const { categoryId, courseId, subCourseId, 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/${subModuleId}` return `/content/human-language/${categoryId}/${courseId}/sub-module/${subCourseId}`
} }
return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}` return `/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`
}, [location.pathname, categoryId, courseId, subModuleId]) }, [location.pathname, categoryId, courseId, subCourseId])
const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => { const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => {
if (type === "TRUE_FALSE") { if (type === "TRUE_FALSE") {

View File

@ -11,7 +11,7 @@ import {
getCourseCategories, getCourseCategories,
getCoursesByCategory, getCoursesByCategory,
getQuestionById, getQuestionById,
getSubModulesByCourse, getSubCoursesByCourse,
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 getSubModulesByCourse(course.id) const res = await getSubCoursesByCourse(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,

View File

@ -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 {
getSubModulesByCourse, getSubCoursesByCourse,
getQuestionSetsByOwner, getQuestionSetsByOwner,
getVideosBySubModule, getVideosBySubCourse,
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 SubModuleContentPage() { export function SubCourseContentPage() {
const { categoryId, courseId, subModuleId } = useParams<{ const { categoryId, courseId, subCourseId } = useParams<{
categoryId: string categoryId: string
courseId: string courseId: string
subModuleId: string subCourseId: string
}>() }>()
const navigate = useNavigate() const navigate = useNavigate()
@ -99,12 +99,12 @@ export function SubModuleContentPage() {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (!subModuleId || !courseId) return if (!subCourseId || !courseId) return
try { try {
const subCoursesRes = await getSubModulesByCourse(Number(courseId)) const subCoursesRes = await getSubCoursesByCourse(Number(courseId))
const foundSubCourse = subCoursesRes.data.data.sub_courses?.find( const foundSubCourse = subCoursesRes.data.data.sub_courses?.find(
(sc) => sc.id === Number(subModuleId) (sc) => sc.id === Number(subCourseId)
) )
setSubCourse(foundSubCourse ?? null) setSubCourse(foundSubCourse ?? null)
} catch (err) { } catch (err) {
@ -116,13 +116,13 @@ export function SubModuleContentPage() {
} }
fetchData() fetchData()
}, [subModuleId, courseId]) }, [subCourseId, courseId])
const fetchPractices = async () => { const fetchPractices = async () => {
if (!subModuleId) return if (!subCourseId) return
setPracticesLoading(true) setPracticesLoading(true)
try { try {
const res = await getQuestionSetsByOwner("SUB_MODULE", Number(subModuleId)) const res = await getQuestionSetsByOwner("SUB_COURSE", Number(subCourseId))
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 SubModuleContentPage() {
} }
const fetchVideos = async () => { const fetchVideos = async () => {
if (!subModuleId) return if (!subCourseId) return
setVideosLoading(true) setVideosLoading(true)
try { try {
const res = await getVideosBySubModule(Number(subModuleId)) const res = await getVideosBySubCourse(Number(subCourseId))
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 SubModuleContentPage() {
} }
const fetchRatings = async (offset = 0) => { const fetchRatings = async (offset = 0) => {
if (!subModuleId) return if (!subCourseId) 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(subModuleId), target_id: Number(subCourseId),
limit: ratingsPageSize, limit: ratingsPageSize,
offset, offset,
}) })
@ -170,7 +170,7 @@ export function SubModuleContentPage() {
} else if (activeTab === "ratings") { } else if (activeTab === "ratings") {
fetchRatings(ratingsPage * ratingsPageSize) fetchRatings(ratingsPage * ratingsPageSize)
} }
}, [activeTab, subModuleId]) }, [activeTab, subCourseId])
useEffect(() => { useEffect(() => {
if (activeTab === "ratings") { if (activeTab === "ratings") {
@ -179,7 +179,7 @@ export function SubModuleContentPage() {
}, [ratingsPage]) }, [ratingsPage])
const handleAddPractice = () => { const handleAddPractice = () => {
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}/add-practice`) navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}/add-practice`)
} }
@ -238,7 +238,7 @@ export function SubModuleContentPage() {
} }
const handlePracticeClick = (practiceId: number) => { const handlePracticeClick = (practiceId: number) => {
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}/practices/${practiceId}/questions`) navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}/practices/${practiceId}/questions`)
} }
const handleAddVideo = () => { const handleAddVideo = () => {
@ -279,7 +279,7 @@ export function SubModuleContentPage() {
} }
const handleSaveNewVideo = async () => { const handleSaveNewVideo = async () => {
if (!subModuleId || !videoFile) return if (!subCourseId || !videoFile) return
setSaving(true) setSaving(true)
setSaveError(null) setSaveError(null)
try { try {
@ -301,7 +301,7 @@ export function SubModuleContentPage() {
const finalTitle = videoTitle.trim() || videoFile.name const finalTitle = videoTitle.trim() || videoFile.name
await createCourseVideo({ await createCourseVideo({
sub_module_id: Number(subModuleId), sub_course_id: Number(subCourseId),
title: finalTitle, title: finalTitle,
description: videoDescription.trim(), description: videoDescription.trim(),
video_url: finalVideoUrl, video_url: finalVideoUrl,
@ -444,7 +444,7 @@ export function SubModuleContentPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Back Button */} {/* Back Button */}
<Link <Link
to={`/content/category/${categoryId}/courses/${courseId}/sub-modules`} to={`/content/category/${categoryId}/courses/${courseId}/sub-courses`}
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" />

View File

@ -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 {
getSubModulesByCourse, getSubCoursesByCourse,
getCoursesByCategory, getCoursesByCategory,
getCourseCategories, getCourseCategories,
createSubModule, createSubCourse,
updateSubModule, updateSubCourse,
updateSubModuleStatus, updateSubCourseStatus,
deleteSubModule, deleteSubCourse,
getSubModulePrerequisites, getSubCoursePrerequisites,
addSubModulePrerequisite, addSubCoursePrerequisite,
removeSubModulePrerequisite, removeSubCoursePrerequisite,
} 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 SubModulesPage() { export function SubCoursesPage() {
const { categoryId, courseId } = useParams<{ const { categoryId, courseId } = useParams<{
categoryId: string; categoryId: string;
courseId: string; courseId: string;
@ -122,10 +122,10 @@ export function SubModulesPage() {
if (!courseId) return; if (!courseId) return;
try { try {
const subCoursesRes = await getSubModulesByCourse(Number(courseId)); const subCoursesRes = await getSubCoursesByCourse(Number(courseId));
setSubCourses(subCoursesRes.data.data.sub_courses ?? []); setSubCourses(subCoursesRes.data.data.sub_courses ?? []);
} catch (err) { } catch (err) {
console.error("Failed to fetch sub-modules:", err); console.error("Failed to fetch sub-courses:", err);
} }
}; };
@ -135,7 +135,7 @@ export function SubModulesPage() {
try { try {
const results = await Promise.all( const results = await Promise.all(
scs.map((sc) => scs.map((sc) =>
getSubModulePrerequisites(sc.id).then((res) => ({ getSubCoursePrerequisites(sc.id).then((res) => ({
id: sc.id, id: sc.id,
data: res.data.data ?? [], data: res.data.data ?? [],
})), })),
@ -159,7 +159,7 @@ export function SubModulesPage() {
try { try {
const [subCoursesRes, coursesRes, categoriesRes] = await Promise.all([ const [subCoursesRes, coursesRes, categoriesRes] = await Promise.all([
getSubModulesByCourse(Number(courseId)), getSubCoursesByCourse(Number(courseId)),
getCoursesByCategory(Number(categoryId)), getCoursesByCategory(Number(categoryId)),
getCourseCategories(), getCourseCategories(),
]); ]);
@ -176,7 +176,7 @@ export function SubModulesPage() {
); );
setCategory(foundCategory ?? null); setCategory(foundCategory ?? null);
} catch (err) { } catch (err) {
console.error("Failed to fetch sub-modules:", err); console.error("Failed to fetch sub-courses:", err);
setError("Failed to load courses"); setError("Failed to load courses");
} finally { } finally {
setLoading(false); setLoading(false);
@ -195,7 +195,7 @@ export function SubModulesPage() {
const handleToggleStatus = async (subCourse: SubCourse) => { const handleToggleStatus = async (subCourse: SubCourse) => {
setTogglingId(subCourse.id); setTogglingId(subCourse.id);
try { try {
await updateSubModuleStatus(subCourse.id, { await updateSubCourseStatus(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 SubModulesPage() {
setDeleting(true); setDeleting(true);
try { try {
await deleteSubModule(subCourseToDelete.id); await deleteSubCourse(subCourseToDelete.id);
setShowDeleteModal(false); setShowDeleteModal(false);
setSubCourseToDelete(null); setSubCourseToDelete(null);
await fetchSubCourses(); await fetchSubCourses();
@ -264,7 +264,7 @@ export function SubModulesPage() {
? parsedOrder ? parsedOrder
: nextSubCourseDisplayOrder(); : nextSubCourseDisplayOrder();
await createSubModule({ await createSubCourse({
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 SubModulesPage() {
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
try { try {
await updateSubModule(subCourseToEdit.id, { await updateSubCourse(subCourseToEdit.id, {
title, title,
description, description,
level, level,
@ -328,9 +328,9 @@ export function SubModulesPage() {
} }
}; };
const handleSubModuleClick = (subModuleId: number) => { const handleSubCourseClick = (subCourseId: number) => {
navigate( navigate(
`/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`, `/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`,
); );
}; };
@ -340,7 +340,7 @@ export function SubModulesPage() {
setPrereqLoading(true); setPrereqLoading(true);
setSelectedPrereqId(0); setSelectedPrereqId(0);
try { try {
const res = await getSubModulePrerequisites(subCourse.id); const res = await getSubCoursePrerequisites(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 SubModulesPage() {
if (!prereqSubCourse || !selectedPrereqId) return; if (!prereqSubCourse || !selectedPrereqId) return;
setPrereqAdding(true); setPrereqAdding(true);
try { try {
await addSubModulePrerequisite(prereqSubCourse.id, { await addSubCoursePrerequisite(prereqSubCourse.id, {
prerequisite_sub_course_id: selectedPrereqId, prerequisite_sub_course_id: selectedPrereqId,
}); });
const res = await getSubModulePrerequisites(prereqSubCourse.id); const res = await getSubCoursePrerequisites(prereqSubCourse.id);
setPrerequisites(res.data.data ?? []); setPrerequisites(res.data.data ?? []);
setSelectedPrereqId(0); setSelectedPrereqId(0);
} catch (err) { } catch (err) {
@ -371,8 +371,8 @@ export function SubModulesPage() {
if (!prereqSubCourse) return; if (!prereqSubCourse) return;
setPrereqRemoving(prereqId); setPrereqRemoving(prereqId);
try { try {
await removeSubModulePrerequisite(prereqSubCourse.id, prereqId); await removeSubCoursePrerequisite(prereqSubCourse.id, prereqId);
const res = await getSubModulePrerequisites(prereqSubCourse.id); const res = await getSubCoursePrerequisites(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 SubModulesPage() {
const flowLayers = (() => { const flowLayers = (() => {
if (subCourses.length === 0) return []; if (subCourses.length === 0) return [];
// Find sub-modules with no prerequisites (roots) // Find sub-courses 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 SubModulesPage() {
<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={() => handleSubModuleClick(subCourse.id)} onClick={() => handleSubCourseClick(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 SubModulesPage() {
</div> </div>
)} )}
{/* Add Sub-module Modal */} {/* Add Sub-course Modal — POST /course-management/sub-courses */}
{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

View File

@ -25,7 +25,6 @@ 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
@ -192,11 +191,9 @@ 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
@ -214,7 +211,7 @@ export interface GetSubCoursesResponse {
metadata: unknown metadata: unknown
} }
/** Compatibility request used to create sub-modules */ /** POST /course-management/sub-courses */
export interface CreateSubCourseRequest { export interface CreateSubCourseRequest {
course_id: number course_id: number
title: string title: string
@ -240,8 +237,7 @@ 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
@ -263,8 +259,7 @@ 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
@ -274,8 +269,7 @@ 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
@ -287,8 +281,7 @@ 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
@ -305,13 +298,9 @@ 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
@ -329,12 +318,9 @@ 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
} }
@ -404,7 +390,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" | "SUB_MODULE" | "COURSE" export type QuestionSetOwnerType = "SUB_COURSE" | "COURSE"
export interface QuestionSet { export interface QuestionSet {
id: number id: number
@ -429,7 +415,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" | "SUB_MODULE" | "COURSE" | string owner_type?: "SUB_COURSE" | "COURSE" | string
owner_id?: number owner_id?: number
status?: "DRAFT" | "PUBLISHED" | "ARCHIVED" | string status?: "DRAFT" | "PUBLISHED" | "ARCHIVED" | string
limit?: number limit?: number