Content admin: course hierarchy, sub-categories API, and stability fixes
- Sub-categories: load from GET categories/:id/sub-categories; SubCategoryCoursesPage - Course structure: levels/modules/sub-modules APIs; SubCoursesPage hierarchy browser - Sub-module detail: HumanLanguageSubModulePage for category routes; resolveSubModuleForCourse - Types and courses API: module sub-modules endpoint, hierarchy array guard - Misc: AppLayout/IssuesPage fixes, CoursesPage refactor, Human Language hierarchy page Made-with: Cursor
This commit is contained in:
parent
f6344c19f9
commit
73f11ea1a0
|
|
@ -49,7 +49,18 @@ import type {
|
||||||
GetLearningPathResponse,
|
GetLearningPathResponse,
|
||||||
GetSubModuleLessonDetailResponse,
|
GetSubModuleLessonDetailResponse,
|
||||||
GetHumanLanguageLessonsResponse,
|
GetHumanLanguageLessonsResponse,
|
||||||
GetHumanLanguageHierarchyResponse,
|
GetSubModuleLessonsResponse,
|
||||||
|
GetHumanLanguageSubCategoriesResponse,
|
||||||
|
GetCategorySubCategoriesResponse,
|
||||||
|
GetSubCategoryCoursesResponse,
|
||||||
|
GetCourseLevelsForCourseResponse,
|
||||||
|
GetCourseLevelsAllResponse,
|
||||||
|
GetCourseLevelByIdResponse,
|
||||||
|
GetHumanLanguageHierarchyFlatResponse,
|
||||||
|
GetCourseHierarchyResponse,
|
||||||
|
GetSubModulesByModuleResponse,
|
||||||
|
CourseHierarchyRow,
|
||||||
|
SubCourse,
|
||||||
CreateHumanLanguageLessonRequest,
|
CreateHumanLanguageLessonRequest,
|
||||||
GetSubCourseEntryAssessmentResponse,
|
GetSubCourseEntryAssessmentResponse,
|
||||||
ReorderItem,
|
ReorderItem,
|
||||||
|
|
@ -57,6 +68,8 @@ import type {
|
||||||
GetRatingsParams,
|
GetRatingsParams,
|
||||||
GetVimeoSampleResponse,
|
GetVimeoSampleResponse,
|
||||||
CreateCourseVideoRequest,
|
CreateCourseVideoRequest,
|
||||||
|
UpdateSubModuleLessonRequest,
|
||||||
|
UpdateSubModuleLessonResponse,
|
||||||
} from "../types/course.types"
|
} from "../types/course.types"
|
||||||
|
|
||||||
type UnifiedHierarchyRow = {
|
type UnifiedHierarchyRow = {
|
||||||
|
|
@ -68,17 +81,6 @@ type UnifiedHierarchyRow = {
|
||||||
course_title?: string | 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
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withSingleRetry<T>(request: () => Promise<T>, retryDelayMs = 400): Promise<T> {
|
async function withSingleRetry<T>(request: () => Promise<T>, retryDelayMs = 400): Promise<T> {
|
||||||
try {
|
try {
|
||||||
return await request()
|
return await request()
|
||||||
|
|
@ -169,6 +171,26 @@ export const deleteCourseCategory = (categoryId: number) =>
|
||||||
export const deleteCourseSubCategory = (subCategoryId: number) =>
|
export const deleteCourseSubCategory = (subCategoryId: number) =>
|
||||||
http.delete(`/course-management/sub-categories/${subCategoryId}`)
|
http.delete(`/course-management/sub-categories/${subCategoryId}`)
|
||||||
|
|
||||||
|
export const getSubCategoriesByCategoryId = (categoryId: number) =>
|
||||||
|
http.get<GetCategorySubCategoriesResponse>(`/course-management/categories/${categoryId}/sub-categories`)
|
||||||
|
|
||||||
|
export const createSubCategory = (payload: {
|
||||||
|
category_id: number
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
display_order?: number
|
||||||
|
}) => http.post("/course-management/sub-categories", payload)
|
||||||
|
|
||||||
|
export const updateSubCategory = (
|
||||||
|
subCategoryId: number,
|
||||||
|
payload: Partial<{
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
is_active: boolean
|
||||||
|
display_order: number
|
||||||
|
}>,
|
||||||
|
) => http.patch(`/course-management/sub-categories/${subCategoryId}`, payload)
|
||||||
|
|
||||||
export const getCoursesByCategory = (categoryId: number) =>
|
export const getCoursesByCategory = (categoryId: number) =>
|
||||||
withSingleRetry(() => http.get("/course-management/hierarchy")).then((res) => {
|
withSingleRetry(() => http.get("/course-management/hierarchy")).then((res) => {
|
||||||
const rows: UnifiedHierarchyRow[] = res.data?.data ?? []
|
const rows: UnifiedHierarchyRow[] = res.data?.data ?? []
|
||||||
|
|
@ -221,17 +243,39 @@ 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)
|
||||||
|
|
||||||
|
export const getCourseHierarchyByCourseId = (courseId: number) =>
|
||||||
|
http.get<GetCourseHierarchyResponse>(`/course-management/courses/${courseId}/hierarchy`)
|
||||||
|
|
||||||
// Sub-Module APIs (Unified Hierarchy)
|
// Sub-Module APIs (Unified Hierarchy)
|
||||||
export const getSubModulesByCourse = (courseId: number) =>
|
export const getSubModulesByCourse = (courseId: number) =>
|
||||||
http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => {
|
getCourseHierarchyByCourseId(courseId).then((res) => {
|
||||||
const rows: CourseHierarchyRow[] = res.data?.data ?? []
|
const raw = 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 }>()
|
const rows: CourseHierarchyRow[] = Array.isArray(raw) ? raw : []
|
||||||
|
const subModuleMap = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
id: number
|
||||||
|
course_id: number
|
||||||
|
level_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) => {
|
rows.forEach((r, idx) => {
|
||||||
if (!r.sub_module_id) return
|
if (!r.sub_module_id) return
|
||||||
if (!subModuleMap.has(r.sub_module_id)) {
|
const existing = subModuleMap.get(r.sub_module_id)
|
||||||
|
if (!existing) {
|
||||||
subModuleMap.set(r.sub_module_id, {
|
subModuleMap.set(r.sub_module_id, {
|
||||||
id: r.sub_module_id,
|
id: r.sub_module_id,
|
||||||
course_id: courseId,
|
course_id: courseId,
|
||||||
|
level_id: r.level_id ?? undefined,
|
||||||
module_id: r.module_id ?? undefined,
|
module_id: r.module_id ?? undefined,
|
||||||
title: r.sub_module_title ?? "",
|
title: r.sub_module_title ?? "",
|
||||||
description: "",
|
description: "",
|
||||||
|
|
@ -242,7 +286,17 @@ export const getSubModulesByCourse = (courseId: number) =>
|
||||||
sub_level: r.cefr_level ?? undefined,
|
sub_level: r.cefr_level ?? undefined,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
subModuleMap.set(r.sub_module_id, {
|
||||||
|
...existing,
|
||||||
|
module_id: existing.module_id ?? r.module_id ?? undefined,
|
||||||
|
level_id: existing.level_id ?? r.level_id ?? undefined,
|
||||||
|
title: existing.title || r.sub_module_title || "",
|
||||||
|
level: existing.level || r.cefr_level || "",
|
||||||
|
cefr_level: existing.cefr_level ?? r.cefr_level ?? undefined,
|
||||||
|
sub_level: existing.sub_level ?? r.cefr_level ?? undefined,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
const sub_courses = Array.from(subModuleMap.values())
|
const sub_courses = Array.from(subModuleMap.values())
|
||||||
return {
|
return {
|
||||||
|
|
@ -299,6 +353,11 @@ export const deleteSubModule = (subModuleId: number) =>
|
||||||
export const getVideosBySubModule = (subModuleId: number) =>
|
export const getVideosBySubModule = (subModuleId: number) =>
|
||||||
http.get<GetSubCourseVideosResponse>(`/course-management/sub-modules/${subModuleId}/videos`)
|
http.get<GetSubCourseVideosResponse>(`/course-management/sub-modules/${subModuleId}/videos`)
|
||||||
|
|
||||||
|
export const getLessonsBySubModule = (subModuleId: number, options?: { includeInactive?: boolean }) =>
|
||||||
|
http.get<GetSubModuleLessonsResponse>(`/course-management/sub-modules/${subModuleId}/lessons`, {
|
||||||
|
params: { include_inactive: options?.includeInactive ?? true },
|
||||||
|
})
|
||||||
|
|
||||||
export const getSubModuleLessonById = (
|
export const getSubModuleLessonById = (
|
||||||
lessonId: number,
|
lessonId: number,
|
||||||
options?: {
|
options?: {
|
||||||
|
|
@ -313,6 +372,14 @@ export const getSubModuleLessonById = (
|
||||||
params: options?.cacheBust ? { _t: Date.now() } : undefined,
|
params: options?.cacheBust ? { _t: Date.now() } : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const updateSubModuleLesson = (lessonId: number, data: UpdateSubModuleLessonRequest) =>
|
||||||
|
http.put<UpdateSubModuleLessonResponse>(`/course-management/sub-module-lessons/${lessonId}`, data)
|
||||||
|
|
||||||
|
export const softDeleteSubModuleLesson = (lessonId: number) =>
|
||||||
|
http.put<UpdateSubModuleLessonResponse>(`/course-management/sub-module-lessons/${lessonId}`, {
|
||||||
|
is_active: false,
|
||||||
|
})
|
||||||
|
|
||||||
export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) =>
|
export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) =>
|
||||||
http.post("/course-management/sub-module-videos", {
|
http.post("/course-management/sub-module-videos", {
|
||||||
sub_module_id: data.sub_module_id ?? data.sub_course_id,
|
sub_module_id: data.sub_module_id ?? data.sub_course_id,
|
||||||
|
|
@ -631,271 +698,93 @@ export const getHumanLanguageLessonsByCourse = (courseId: number, cefr_level: st
|
||||||
params: { cefr_level },
|
params: { cefr_level },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const getHumanLanguageSubCategories = () =>
|
||||||
|
http.get<GetHumanLanguageSubCategoriesResponse>("/course-management/human-language/sub-categories")
|
||||||
|
|
||||||
|
export const getCoursesBySubCategoryId = (subCategoryId: number) =>
|
||||||
|
http.get<GetSubCategoryCoursesResponse>(`/course-management/sub-categories/${subCategoryId}/courses`)
|
||||||
|
|
||||||
|
export const getSubModulesByModuleId = (moduleId: number) =>
|
||||||
|
http.get<GetSubModulesByModuleResponse>(`/course-management/modules/${moduleId}/sub-modules`)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a sub-module under a course by walking levels → modules → sub-modules APIs.
|
||||||
|
* Use when the legacy hierarchy flatten (`getSubModulesByCourse`) does not include the row.
|
||||||
|
*/
|
||||||
|
export async function resolveSubModuleForCourse(
|
||||||
|
courseId: number,
|
||||||
|
subModuleId: number,
|
||||||
|
): Promise<SubCourse | null> {
|
||||||
|
try {
|
||||||
|
const levelsRes = await getCourseLevelsForCourse(courseId)
|
||||||
|
const levels = Array.isArray(levelsRes.data?.data?.levels) ? levelsRes.data.data.levels : []
|
||||||
|
const sortedLevels = [...levels].sort((a, b) => {
|
||||||
|
const o = (a.display_order ?? 0) - (b.display_order ?? 0)
|
||||||
|
if (o !== 0) return o
|
||||||
|
return String(a.cefr_level ?? "").localeCompare(String(b.cefr_level ?? ""))
|
||||||
|
})
|
||||||
|
|
||||||
|
const modulesNested = await Promise.all(
|
||||||
|
sortedLevels.map(async (level) => {
|
||||||
|
const modsRes = await getModulesByLevel(level.id)
|
||||||
|
const rawMods = modsRes.data?.data?.modules
|
||||||
|
const modules = Array.isArray(rawMods) ? rawMods : []
|
||||||
|
const sortedMods = [...modules].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
|
||||||
|
return sortedMods.map((module) => ({ level, module }))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const modulePairs = modulesNested.flat()
|
||||||
|
|
||||||
|
const bundles = await Promise.all(
|
||||||
|
modulePairs.map(async ({ level, module }) => {
|
||||||
|
const subsRes = await getSubModulesByModuleId(module.id)
|
||||||
|
const rawSubs = subsRes.data?.data?.sub_modules
|
||||||
|
const subs = Array.isArray(rawSubs) ? rawSubs : []
|
||||||
|
const sortedSubs = [...subs].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
|
||||||
|
return { level, module, subs: sortedSubs }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const { level, module, subs } of bundles) {
|
||||||
|
const found = subs.find((s) => s.id === subModuleId)
|
||||||
|
if (found) {
|
||||||
|
return {
|
||||||
|
id: found.id,
|
||||||
|
course_id: courseId,
|
||||||
|
level_id: level.id,
|
||||||
|
module_id: module.id,
|
||||||
|
title: found.title,
|
||||||
|
description: found.description ?? "",
|
||||||
|
level: level.cefr_level,
|
||||||
|
cefr_level: level.cefr_level,
|
||||||
|
thumbnail: found.thumbnail ?? "",
|
||||||
|
display_order: found.display_order,
|
||||||
|
sub_level: level.cefr_level,
|
||||||
|
is_active: found.is_active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("resolveSubModuleForCourse failed:", e)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCourseLevelsForCourse = (courseId: number) =>
|
||||||
|
http.get<GetCourseLevelsForCourseResponse>(`/course-management/courses/${courseId}/levels`)
|
||||||
|
|
||||||
|
export const getAllCourseLevels = () => http.get<GetCourseLevelsAllResponse>("/course-management/levels")
|
||||||
|
|
||||||
|
export const getCourseLevelById = (levelId: number) =>
|
||||||
|
http.get<GetCourseLevelByIdResponse>(`/course-management/levels/${levelId}`)
|
||||||
|
|
||||||
export const getHumanLanguageHierarchy = (options?: { cacheBust?: boolean }) =>
|
export const getHumanLanguageHierarchy = (options?: { cacheBust?: boolean }) =>
|
||||||
withSingleRetry(() =>
|
withSingleRetry(() =>
|
||||||
http.get<GetHumanLanguageHierarchyResponse>("/course-management/hierarchy", {
|
http.get<GetHumanLanguageHierarchyFlatResponse>("/course-management/human-language/hierarchy", {
|
||||||
params: options?.cacheBust ? { _t: Date.now() } : undefined,
|
params: options?.cacheBust ? { _t: Date.now() } : undefined,
|
||||||
}),
|
}),
|
||||||
).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 categories = Array.from(categoryMap.values())
|
|
||||||
const humanLanguageCandidates = categories.filter((c) => c.category_name.toLowerCase().includes("human"))
|
|
||||||
|
|
||||||
const selectedCategory = (humanLanguageCandidates.length ? humanLanguageCandidates : categories).sort((a, b) => {
|
|
||||||
const aSubCategoryCount = a.sub_categories.size
|
|
||||||
const bSubCategoryCount = b.sub_categories.size
|
|
||||||
if (aSubCategoryCount !== bSubCategoryCount) return bSubCategoryCount - aSubCategoryCount
|
|
||||||
|
|
||||||
const aCourseCount = Array.from(a.sub_categories.values()).reduce((sum, sub) => sum + sub.courses.size, 0)
|
|
||||||
const bCourseCount = Array.from(b.sub_categories.values()).reduce((sum, sub) => sum + sub.courses.size, 0)
|
|
||||||
if (aCourseCount !== bCourseCount) return bCourseCount - aCourseCount
|
|
||||||
|
|
||||||
// If tied on richness, pick the latest category id.
|
|
||||||
return b.category_id - a.category_id
|
|
||||||
})[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_id?: number
|
|
||||||
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_id: Number(row.level_id), 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: [],
|
|
||||||
lessons: [],
|
|
||||||
practices: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
course_id: course.course_id,
|
|
||||||
course_name: course.course_name,
|
|
||||||
levels: Array.from(levelMap.values()).map((levelNode) => ({
|
|
||||||
level_id: levelNode.level_id,
|
|
||||||
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()),
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const subModuleIds = subCategories.flatMap((sub) =>
|
|
||||||
sub.courses.flatMap((course) =>
|
|
||||||
course.levels.flatMap((levelNode) => levelNode.modules.flatMap((moduleNode) => moduleNode.sub_modules.map((sm) => sm.id))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
type QuestionSetListItem = {
|
|
||||||
id: number
|
|
||||||
title?: string
|
|
||||||
set_type?: string
|
|
||||||
status?: string
|
|
||||||
intro_video_url?: string | null
|
|
||||||
question_count?: number
|
|
||||||
created_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const questionSetsBySubModule = new Map<number, QuestionSetListItem[]>()
|
|
||||||
await Promise.all(
|
|
||||||
subModuleIds.map(async (subModuleID) => {
|
|
||||||
try {
|
|
||||||
const questionSetRes = await http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
|
|
||||||
params: { owner_type: "SUB_MODULE", owner_id: subModuleID },
|
|
||||||
})
|
|
||||||
const payload = questionSetRes.data?.data
|
|
||||||
const sets = Array.isArray(payload)
|
|
||||||
? payload
|
|
||||||
: Array.isArray((payload as { question_sets?: QuestionSetListItem[] } | undefined)?.question_sets)
|
|
||||||
? ((payload as { question_sets: QuestionSetListItem[] }).question_sets ?? [])
|
|
||||||
: []
|
|
||||||
questionSetsBySubModule.set(subModuleID, sets as QuestionSetListItem[])
|
|
||||||
} catch {
|
|
||||||
questionSetsBySubModule.set(subModuleID, [])
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
subCategories.forEach((sub) => {
|
|
||||||
sub.courses.forEach((course) => {
|
|
||||||
course.levels.forEach((levelNode) => {
|
|
||||||
levelNode.modules.forEach((moduleNode) => {
|
|
||||||
moduleNode.sub_modules.forEach((subModuleNode) => {
|
|
||||||
const sets = questionSetsBySubModule.get(subModuleNode.id) ?? []
|
|
||||||
const lessons = sets
|
|
||||||
.filter((set) => String(set.set_type ?? "").toUpperCase() === "QUIZ")
|
|
||||||
.sort((a, b) => {
|
|
||||||
const ad = Date.parse(String(a.created_at ?? "")) || 0
|
|
||||||
const bd = Date.parse(String(b.created_at ?? "")) || 0
|
|
||||||
return ad - bd
|
|
||||||
})
|
|
||||||
.map((set, idx) => ({
|
|
||||||
id: Number(set.id),
|
|
||||||
question_set_id: Number(set.id),
|
|
||||||
title: set.title?.trim() || `Lesson ${idx + 1}`,
|
|
||||||
status: set.status ?? "DRAFT",
|
|
||||||
question_count: Number(set.question_count ?? 0),
|
|
||||||
display_order: idx + 1,
|
|
||||||
intro_video_url: set.intro_video_url ?? null,
|
|
||||||
}))
|
|
||||||
subModuleNode.lessons = lessons
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
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/levels", {
|
.post("/course-management/levels", {
|
||||||
|
|
|
||||||
108
src/api/http.ts
108
src/api/http.ts
|
|
@ -12,6 +12,7 @@ let failedQueue: Array<{
|
||||||
resolve: (token: string) => void;
|
resolve: (token: string) => void;
|
||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
const TOKEN_REFRESH_BUFFER_SECONDS = 120;
|
||||||
|
|
||||||
const processQueue = (error: Error | null, token: string | null = null) => {
|
const processQueue = (error: Error | null, token: string | null = null) => {
|
||||||
failedQueue.forEach((prom) => {
|
failedQueue.forEach((prom) => {
|
||||||
|
|
@ -32,23 +33,47 @@ const clearAuthAndRedirect = () => {
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshAccessToken = async (): Promise<string> => {
|
const decodeJwtPayload = (token: string): Record<string, unknown> | null => {
|
||||||
const accessToken = localStorage.getItem("access_token");
|
try {
|
||||||
const refreshToken = localStorage.getItem("refresh_token");
|
const payloadPart = token.split(".")[1];
|
||||||
const role = localStorage.getItem("role");
|
if (!payloadPart) return null;
|
||||||
const memberId = localStorage.getItem("member_id");
|
const normalized = payloadPart.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||||
|
const json = atob(padded);
|
||||||
|
return JSON.parse(json) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!refreshToken || !memberId) {
|
const isAccessTokenExpiringSoon = (token: string) => {
|
||||||
|
const payload = decodeJwtPayload(token);
|
||||||
|
const exp = Number(payload?.exp);
|
||||||
|
if (!Number.isFinite(exp)) return true;
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
return exp - nowSeconds <= TOKEN_REFRESH_BUFFER_SECONDS;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAuthEndpointRequest = (url?: string) => {
|
||||||
|
if (!url) return false;
|
||||||
|
return (
|
||||||
|
url.includes("/team/login") ||
|
||||||
|
url.includes("/team/google-login") ||
|
||||||
|
url.includes("/team/refresh")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshAccessToken = async (): Promise<string> => {
|
||||||
|
const refreshToken = localStorage.getItem("refresh_token");
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
throw new Error("No refresh token available");
|
throw new Error("No refresh token available");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
|
`${import.meta.env.VITE_API_BASE_URL}/team/refresh`,
|
||||||
{
|
{
|
||||||
access_token: accessToken,
|
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
role: role || "admin",
|
|
||||||
member_id: Number(memberId),
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -65,9 +90,43 @@ const refreshAccessToken = async (): Promise<string> => {
|
||||||
return newAccessToken;
|
return newAccessToken;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getValidAccessToken = async (forceRefresh = false): Promise<string> => {
|
||||||
|
const currentToken = localStorage.getItem("access_token");
|
||||||
|
if (!forceRefresh && currentToken && !isAccessTokenExpiringSoon(currentToken)) {
|
||||||
|
return currentToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing = true;
|
||||||
|
try {
|
||||||
|
const newToken = await refreshAccessToken();
|
||||||
|
processQueue(null, newToken);
|
||||||
|
return newToken;
|
||||||
|
} catch (refreshError) {
|
||||||
|
processQueue(refreshError as Error, null);
|
||||||
|
clearAuthAndRedirect();
|
||||||
|
throw refreshError;
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Attach access token to every request
|
// Attach access token to every request
|
||||||
http.interceptors.request.use((config) => {
|
http.interceptors.request.use(async (config) => {
|
||||||
const token = localStorage.getItem("access_token");
|
if (isAuthEndpointRequest(config.url)) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = localStorage.getItem("access_token");
|
||||||
|
if (token && isAccessTokenExpiringSoon(token)) {
|
||||||
|
token = await getValidAccessToken();
|
||||||
|
}
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
@ -80,32 +139,19 @@ http.interceptors.response.use(
|
||||||
async (error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (
|
||||||
if (isRefreshing) {
|
error.response?.status === 401 &&
|
||||||
return new Promise((resolve, reject) => {
|
!originalRequest._retry &&
|
||||||
failedQueue.push({ resolve, reject });
|
!isAuthEndpointRequest(originalRequest.url)
|
||||||
})
|
) {
|
||||||
.then((token) => {
|
|
||||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
|
||||||
return http(originalRequest);
|
|
||||||
})
|
|
||||||
.catch((err) => Promise.reject(err));
|
|
||||||
}
|
|
||||||
|
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
isRefreshing = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newToken = await refreshAccessToken();
|
const newToken = await getValidAccessToken(true);
|
||||||
processQueue(null, newToken);
|
|
||||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||||
return http(originalRequest);
|
return http(originalRequest);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
processQueue(refreshError as Error, null);
|
|
||||||
clearAuthAndRedirect();
|
|
||||||
return Promise.reject(refreshError);
|
return Promise.reject(refreshError);
|
||||||
} finally {
|
|
||||||
isRefreshing = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuest
|
||||||
import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage"
|
import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage"
|
||||||
import { AddNewLessonPage } from "../pages/content-management/AddNewLessonPage"
|
import { AddNewLessonPage } from "../pages/content-management/AddNewLessonPage"
|
||||||
import { SubModulesPage } from "../pages/content-management/SubCoursesPage"
|
import { SubModulesPage } from "../pages/content-management/SubCoursesPage"
|
||||||
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"
|
||||||
|
|
@ -32,8 +31,9 @@ import { PracticeDetailsPage } from "../pages/content-management/PracticeDetails
|
||||||
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage"
|
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage"
|
||||||
import { QuestionsPage } from "../pages/content-management/QuestionsPage"
|
import { QuestionsPage } from "../pages/content-management/QuestionsPage"
|
||||||
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
|
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
|
||||||
import { HumanLanguagePage } from "../pages/content-management/HumanLanguagePage"
|
import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLanguageHierarchyPage"
|
||||||
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage"
|
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage"
|
||||||
|
import { SubCategoryCoursesPage } from "../pages/content-management/SubCategoryCoursesPage"
|
||||||
import { UserLogPage } from "../pages/user-log/UserLogPage"
|
import { UserLogPage } from "../pages/user-log/UserLogPage"
|
||||||
import { IssuesPage } from "../pages/issues/IssuesPage"
|
import { IssuesPage } from "../pages/issues/IssuesPage"
|
||||||
import { ProfilePage } from "../pages/ProfilePage"
|
import { ProfilePage } from "../pages/ProfilePage"
|
||||||
|
|
@ -79,7 +79,7 @@ export function AppRoutes() {
|
||||||
<Route index element={<CourseCategoryPage />} />
|
<Route index element={<CourseCategoryPage />} />
|
||||||
<Route path="courses" element={<AllCoursesPage />} />
|
<Route path="courses" element={<AllCoursesPage />} />
|
||||||
<Route path="flows" element={<CourseFlowBuilderPage />} />
|
<Route path="flows" element={<CourseFlowBuilderPage />} />
|
||||||
<Route path="human-language" element={<HumanLanguagePage />} />
|
<Route path="human-language" element={<HumanLanguageHierarchyPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice"
|
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice"
|
||||||
element={<AddNewPracticePage />}
|
element={<AddNewPracticePage />}
|
||||||
|
|
@ -92,21 +92,29 @@ export function AppRoutes() {
|
||||||
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/practices/:practiceId/questions"
|
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/practices/:practiceId/questions"
|
||||||
element={<PracticeQuestionsPage />}
|
element={<PracticeQuestionsPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="human-language/:categoryId/:courseId/level/:levelId/practices/:practiceId/questions"
|
||||||
|
element={<PracticeQuestionsPage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="human-language/:categoryId/:courseId/sub-module/:subModuleId"
|
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/sub-categories/:subCategoryId/courses"
|
||||||
|
element={<SubCategoryCoursesPage />}
|
||||||
|
/>
|
||||||
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
|
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
|
||||||
{/* Course → Sub-module → Lesson/Practice */}
|
{/* Course → Sub-module → Lesson/Practice */}
|
||||||
<Route path="category/:categoryId/courses/:courseId/sub-modules" element={<SubModulesPage />} />
|
<Route path="category/:categoryId/courses/:courseId/sub-modules" element={<SubModulesPage />} />
|
||||||
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId" element={<SubModuleContentPage />} />
|
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId" element={<HumanLanguageSubModulePage />} />
|
||||||
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-practice" element={<AddNewPracticePage />} />
|
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-practice" element={<AddNewPracticePage />} />
|
||||||
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-lesson" element={<AddNewLessonPage />} />
|
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-lesson" element={<AddNewLessonPage />} />
|
||||||
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
|
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
|
||||||
{/* Legacy aliases */}
|
{/* Legacy aliases */}
|
||||||
<Route path="category/:categoryId/courses/:courseId/sub-courses" element={<SubModulesPage />} />
|
<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" element={<HumanLanguageSubModulePage />} />
|
||||||
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-practice" element={<AddNewPracticePage />} />
|
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-practice" element={<AddNewPracticePage />} />
|
||||||
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-lesson" element={<AddNewLessonPage />} />
|
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-lesson" element={<AddNewLessonPage />} />
|
||||||
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
|
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,6 @@ export function AppLayout() {
|
||||||
const routeKey = useMemo(() => `${location.pathname}${location.search}`, [location.pathname, location.search])
|
const routeKey = useMemo(() => `${location.pathname}${location.search}`, [location.pathname, location.search])
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token")
|
const token = localStorage.getItem("access_token")
|
||||||
if (!token) {
|
|
||||||
return <Navigate to="/login" replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSidebarToggle = useCallback(() => {
|
const handleSidebarToggle = useCallback(() => {
|
||||||
setSidebarOpen((prev) => !prev)
|
setSidebarOpen((prev) => !prev)
|
||||||
|
|
@ -58,6 +55,10 @@ export function AppLayout() {
|
||||||
}
|
}
|
||||||
}, [routeKey])
|
}, [routeKey])
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-grayScale-100">
|
<div className="flex min-h-screen bg-grayScale-100">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,27 @@
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom"
|
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||||
import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle, Star, MessageSquare, ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react"
|
import {
|
||||||
|
Plus,
|
||||||
|
ArrowLeft,
|
||||||
|
ToggleLeft,
|
||||||
|
ToggleRight,
|
||||||
|
X,
|
||||||
|
Trash2,
|
||||||
|
Edit,
|
||||||
|
AlertCircle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Search,
|
||||||
|
BookOpen,
|
||||||
|
Eye,
|
||||||
|
} from "lucide-react"
|
||||||
import practiceSrc from "../../assets/Practice.svg"
|
import practiceSrc from "../../assets/Practice.svg"
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
|
||||||
import alertSrc from "../../assets/Alert.svg"
|
import alertSrc from "../../assets/Alert.svg"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Badge } from "../../components/ui/badge"
|
import { Badge } from "../../components/ui/badge"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { FileUpload } from "../../components/ui/file-upload"
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -18,24 +31,19 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table"
|
} from "../../components/ui/table"
|
||||||
import {
|
import {
|
||||||
getCoursesByCategory,
|
getSubCategoriesByCategoryId,
|
||||||
getCourseCategories,
|
getCourseCategories,
|
||||||
createCourse,
|
createSubCategory,
|
||||||
deleteCourse,
|
deleteCourseSubCategory,
|
||||||
updateCourseStatus,
|
updateSubCategory,
|
||||||
updateCourse,
|
|
||||||
updateCourseThumbnail,
|
|
||||||
getRatings,
|
|
||||||
} from "../../api/courses.api"
|
} from "../../api/courses.api"
|
||||||
import { uploadImageFile } from "../../api/files.api"
|
import type { CategorySubCategoryListItem, CourseCategory } from "../../types/course.types"
|
||||||
import type { Course, CourseCategory, Rating } from "../../types/course.types"
|
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
|
||||||
|
|
||||||
export function CoursesPage() {
|
export function CoursesPage() {
|
||||||
const { categoryId } = useParams<{ categoryId: string }>()
|
const { categoryId } = useParams<{ categoryId: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [courses, setCourses] = useState<Course[]>([])
|
const [subCategories, setSubCategories] = useState<CategorySubCategoryListItem[]>([])
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const [category, setCategory] = useState<CourseCategory | null>(null)
|
const [category, setCategory] = useState<CourseCategory | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -44,36 +52,32 @@ export function CoursesPage() {
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [title, setTitle] = useState("")
|
const [title, setTitle] = useState("")
|
||||||
const [description, setDescription] = useState("")
|
const [description, setDescription] = useState("")
|
||||||
|
const [displayOrder, setDisplayOrder] = useState("")
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
const [courseToDelete, setCourseToDelete] = useState<Course | null>(null)
|
const [subCategoryToDelete, setSubCategoryToDelete] = useState<CategorySubCategoryListItem | null>(null)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||||
const [showEditModal, setShowEditModal] = useState(false)
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
const [courseToEdit, setCourseToEdit] = useState<Course | null>(null)
|
const [subCategoryToEdit, setSubCategoryToEdit] = useState<CategorySubCategoryListItem | null>(null)
|
||||||
const [editTitle, setEditTitle] = useState("")
|
const [editTitle, setEditTitle] = useState("")
|
||||||
const [editDescription, setEditDescription] = useState("")
|
const [editDescription, setEditDescription] = useState("")
|
||||||
const [editThumbnail, setEditThumbnail] = useState("")
|
const [editDisplayOrder, setEditDisplayOrder] = useState(0)
|
||||||
const [editThumbnailFile, setEditThumbnailFile] = useState<File | null>(null)
|
|
||||||
const [updating, setUpdating] = useState(false)
|
const [updating, setUpdating] = useState(false)
|
||||||
const [updateError, setUpdateError] = useState<string | null>(null)
|
const [updateError, setUpdateError] = useState<string | null>(null)
|
||||||
const [showRatingsModal, setShowRatingsModal] = useState(false)
|
|
||||||
const [ratingsCourseId, setRatingsCourseId] = useState<number | null>(null)
|
|
||||||
const [courseRatings, setCourseRatings] = useState<Rating[]>([])
|
|
||||||
const [courseRatingsLoading, setCourseRatingsLoading] = useState(false)
|
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(10)
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
|
||||||
const fetchCourses = async () => {
|
const fetchSubCategories = async () => {
|
||||||
if (!categoryId) return
|
if (!categoryId) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const coursesRes = await getCoursesByCategory(Number(categoryId))
|
const res = await getSubCategoriesByCategoryId(Number(categoryId))
|
||||||
console.log("Courses response:", coursesRes.data.data.courses)
|
const raw = res.data?.data?.sub_categories
|
||||||
setCourses(coursesRes.data.data.courses ?? [])
|
setSubCategories(Array.isArray(raw) ? raw : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch courses:", err)
|
console.error("Failed to fetch sub-categories:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,18 +86,19 @@ export function CoursesPage() {
|
||||||
if (!categoryId) return
|
if (!categoryId) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [coursesRes, categoriesRes] = await Promise.all([
|
const [subRes, categoriesRes] = await Promise.all([
|
||||||
getCoursesByCategory(Number(categoryId)),
|
getSubCategoriesByCategoryId(Number(categoryId)),
|
||||||
getCourseCategories(),
|
getCourseCategories(),
|
||||||
])
|
])
|
||||||
|
|
||||||
setCourses(coursesRes.data.data.courses ?? [])
|
const raw = subRes.data?.data?.sub_categories
|
||||||
const foundCategory = categoriesRes.data.data.categories.find(
|
setSubCategories(Array.isArray(raw) ? raw : [])
|
||||||
(c) => c.id === Number(categoryId)
|
const foundCategory = categoriesRes.data?.data?.categories?.find(
|
||||||
|
(c) => c.id === Number(categoryId),
|
||||||
)
|
)
|
||||||
setCategory(foundCategory ?? null)
|
setCategory(foundCategory ?? null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch courses:", err)
|
console.error("Failed to fetch sub-categories:", err)
|
||||||
setError("Failed to load sub-categories")
|
setError("Failed to load sub-categories")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|
@ -110,6 +115,7 @@ export function CoursesPage() {
|
||||||
const handleOpenModal = () => {
|
const handleOpenModal = () => {
|
||||||
setTitle("")
|
setTitle("")
|
||||||
setDescription("")
|
setDescription("")
|
||||||
|
setDisplayOrder("")
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
setShowModal(true)
|
setShowModal(true)
|
||||||
}
|
}
|
||||||
|
|
@ -118,16 +124,13 @@ export function CoursesPage() {
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
setTitle("")
|
setTitle("")
|
||||||
setDescription("")
|
setDescription("")
|
||||||
|
setDisplayOrder("")
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
setSaveError("Title is required")
|
setSaveError("Name is required")
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!description.trim()) {
|
|
||||||
setSaveError("Description is required")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,13 +138,15 @@ export function CoursesPage() {
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createCourse({
|
const orderParsed = parseInt(displayOrder.trim(), 10)
|
||||||
|
await createSubCategory({
|
||||||
category_id: Number(categoryId),
|
category_id: Number(categoryId),
|
||||||
title: title.trim(),
|
name: title.trim(),
|
||||||
description: description.trim(),
|
description: description.trim() || null,
|
||||||
|
...(Number.isFinite(orderParsed) && orderParsed >= 0 ? { display_order: orderParsed } : {}),
|
||||||
})
|
})
|
||||||
handleCloseModal()
|
handleCloseModal()
|
||||||
await fetchCourses()
|
await fetchSubCategories()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to create course:", err)
|
console.error("Failed to create course:", err)
|
||||||
setSaveError(err.response?.data?.message || "Failed to create sub-category")
|
setSaveError(err.response?.data?.message || "Failed to create sub-category")
|
||||||
|
|
@ -150,20 +155,20 @@ export function CoursesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteClick = (course: Course) => {
|
const handleDeleteClick = (sub: CategorySubCategoryListItem) => {
|
||||||
setCourseToDelete(course)
|
setSubCategoryToDelete(sub)
|
||||||
setShowDeleteModal(true)
|
setShowDeleteModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConfirmDelete = async () => {
|
const handleConfirmDelete = async () => {
|
||||||
if (!courseToDelete) return
|
if (!subCategoryToDelete) return
|
||||||
|
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
try {
|
try {
|
||||||
await deleteCourse(courseToDelete.id)
|
await deleteCourseSubCategory(subCategoryToDelete.id)
|
||||||
setShowDeleteModal(false)
|
setShowDeleteModal(false)
|
||||||
setCourseToDelete(null)
|
setSubCategoryToDelete(null)
|
||||||
await fetchCourses()
|
await fetchSubCategories()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete course:", err)
|
console.error("Failed to delete course:", err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -171,11 +176,11 @@ export function CoursesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleStatus = async (course: Course) => {
|
const handleToggleStatus = async (sub: CategorySubCategoryListItem) => {
|
||||||
setTogglingId(course.id)
|
setTogglingId(sub.id)
|
||||||
try {
|
try {
|
||||||
await updateCourseStatus(course.id, !course.is_active)
|
await updateSubCategory(sub.id, { is_active: !sub.is_active })
|
||||||
await fetchCourses()
|
await fetchSubCategories()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update course status:", err)
|
console.error("Failed to update course status:", err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -183,35 +188,29 @@ export function CoursesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditClick = (course: Course) => {
|
const handleEditClick = (sub: CategorySubCategoryListItem) => {
|
||||||
setCourseToEdit(course)
|
setSubCategoryToEdit(sub)
|
||||||
setEditTitle(course.title || "")
|
setEditTitle(sub.name || "")
|
||||||
setEditDescription(course.description || "")
|
setEditDescription(sub.description ?? "")
|
||||||
setEditThumbnail(course.thumbnail || "")
|
setEditDisplayOrder(sub.display_order ?? 0)
|
||||||
setEditThumbnailFile(null)
|
|
||||||
setUpdateError(null)
|
setUpdateError(null)
|
||||||
setShowEditModal(true)
|
setShowEditModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCloseEditModal = () => {
|
const handleCloseEditModal = () => {
|
||||||
setShowEditModal(false)
|
setShowEditModal(false)
|
||||||
setCourseToEdit(null)
|
setSubCategoryToEdit(null)
|
||||||
setEditTitle("")
|
setEditTitle("")
|
||||||
setEditDescription("")
|
setEditDescription("")
|
||||||
setEditThumbnail("")
|
setEditDisplayOrder(0)
|
||||||
setEditThumbnailFile(null)
|
|
||||||
setUpdateError(null)
|
setUpdateError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
const handleUpdate = async () => {
|
||||||
if (!courseToEdit) return
|
if (!subCategoryToEdit) return
|
||||||
|
|
||||||
if (!editTitle.trim()) {
|
if (!editTitle.trim()) {
|
||||||
setUpdateError("Title is required")
|
setUpdateError("Name is required")
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!editDescription.trim()) {
|
|
||||||
setUpdateError("Description is required")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,23 +218,15 @@ export function CoursesPage() {
|
||||||
setUpdateError(null)
|
setUpdateError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateCourse(courseToEdit.id, {
|
await updateSubCategory(subCategoryToEdit.id, {
|
||||||
title: editTitle.trim(),
|
name: editTitle.trim(),
|
||||||
description: editDescription.trim(),
|
description: editDescription.trim() || null,
|
||||||
is_active: courseToEdit.is_active,
|
display_order: Math.max(0, Number(editDisplayOrder) || 0),
|
||||||
|
is_active: subCategoryToEdit.is_active,
|
||||||
})
|
})
|
||||||
|
|
||||||
const thumbnailUrl =
|
|
||||||
editThumbnailFile
|
|
||||||
? (await uploadImageFile(editThumbnailFile)).data?.data?.url?.trim()
|
|
||||||
: editThumbnail.trim() || ""
|
|
||||||
|
|
||||||
if (thumbnailUrl) {
|
|
||||||
await updateCourseThumbnail(courseToEdit.id, thumbnailUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCloseEditModal()
|
handleCloseEditModal()
|
||||||
await fetchCourses()
|
await fetchSubCategories()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to update course:", err)
|
console.error("Failed to update course:", err)
|
||||||
setUpdateError(err.response?.data?.message || "Failed to update sub-category")
|
setUpdateError(err.response?.data?.message || "Failed to update sub-category")
|
||||||
|
|
@ -244,32 +235,19 @@ export function CoursesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCourseClick = (courseId: number) => {
|
const handleOpenSubCategory = (subCategoryId: number) => {
|
||||||
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-modules`)
|
navigate(`/content/category/${categoryId}/sub-categories/${subCategoryId}/courses`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleViewRatings = async (courseId: number) => {
|
const filteredSubCategories = useMemo(() => {
|
||||||
setRatingsCourseId(courseId)
|
|
||||||
setShowRatingsModal(true)
|
|
||||||
setCourseRatingsLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await getRatings({ target_type: "course", target_id: courseId, limit: 10 })
|
|
||||||
setCourseRatings(res.data.data ?? [])
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch ratings:", err)
|
|
||||||
} finally {
|
|
||||||
setCourseRatingsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredCourses = useMemo(() => {
|
|
||||||
const q = searchQuery.trim().toLowerCase()
|
const q = searchQuery.trim().toLowerCase()
|
||||||
if (!q) return courses
|
if (!q) return subCategories
|
||||||
return courses.filter((course) => {
|
return subCategories.filter((sub) => {
|
||||||
const haystack = `${course.title} ${course.description ?? ""} ${course.id}`.toLowerCase()
|
const haystack =
|
||||||
|
`${sub.name} ${sub.description ?? ""} ${sub.category_name} ${sub.id} ${sub.display_order}`.toLowerCase()
|
||||||
return haystack.includes(q)
|
return haystack.includes(q)
|
||||||
})
|
})
|
||||||
}, [courses, searchQuery])
|
}, [subCategories, searchQuery])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -290,13 +268,29 @@ export function CoursesPage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalCount = filteredCourses.length
|
const totalCount = filteredSubCategories.length
|
||||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
|
||||||
const safePage = Math.min(page, totalPages)
|
const safePage = Math.min(page, totalPages)
|
||||||
const paginatedCourses = filteredCourses.slice((safePage - 1) * pageSize, safePage * pageSize)
|
const paginatedSubCategories = filteredSubCategories.slice((safePage - 1) * pageSize, safePage * pageSize)
|
||||||
const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1
|
const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1
|
||||||
const endEntry = Math.min(safePage * pageSize, totalCount)
|
const endEntry = Math.min(safePage * pageSize, totalCount)
|
||||||
|
|
||||||
|
const formatId = (id: number) => `#${id}`
|
||||||
|
|
||||||
|
const formatCreatedAt = (iso: string) => {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getPageNumbers = () => {
|
const getPageNumbers = () => {
|
||||||
const pages: (number | string)[] = []
|
const pages: (number | string)[] = []
|
||||||
if (totalPages <= 7) {
|
if (totalPages <= 7) {
|
||||||
|
|
@ -328,7 +322,7 @@ export function CoursesPage() {
|
||||||
{category?.name} Sub-categories
|
{category?.name} Sub-categories
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-0.5 text-sm text-grayScale-400">
|
<p className="mt-0.5 text-sm text-grayScale-400">
|
||||||
<span className="font-medium text-grayScale-500">{courses.length}</span> sub-categories available
|
<span className="font-medium text-grayScale-500">{subCategories.length}</span> sub-categories available
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -339,15 +333,11 @@ export function CoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Course table or empty state */}
|
{/* Sub-category table — layout aligned with Activity Log (UserLogPage) */}
|
||||||
<Card className="shadow-soft">
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-white p-4">
|
||||||
<CardHeader className="border-b border-grayScale-200 pb-3">
|
<h2 className="text-base font-semibold text-grayScale-600">Sub-category Management</h2>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="relative w-full min-w-[200px] flex-1 sm:max-w-xs sm:flex-initial">
|
||||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||||
Sub-category Management
|
|
||||||
</CardTitle>
|
|
||||||
<div className="relative w-full sm:max-w-xs">
|
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
|
|
||||||
<Input
|
<Input
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
|
@ -356,15 +346,13 @@ export function CoursesPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-4">
|
{subCategories.length === 0 ? (
|
||||||
{courses.length === 0 ? (
|
<div className="rounded-xl border bg-white">
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center">
|
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||||
<img src={practiceSrc} alt="" className="h-16 w-16" />
|
<img src={practiceSrc} alt="" className="h-16 w-16" />
|
||||||
<h3 className="mt-4 text-base font-semibold text-grayScale-600">No sub-categories yet</h3>
|
<h3 className="mt-4 text-base font-semibold text-grayScale-600">No sub-categories yet</h3>
|
||||||
<p className="mt-1.5 text-sm text-grayScale-400">
|
<p className="mt-1.5 text-sm text-grayScale-400">No sub-categories found in this category.</p>
|
||||||
No sub-categories found in this category.
|
|
||||||
</p>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="mt-5 border-brand-200 text-brand-600 transition-colors hover:bg-brand-50"
|
className="mt-5 border-brand-200 text-brand-600 transition-colors hover:bg-brand-50"
|
||||||
|
|
@ -374,60 +362,90 @@ export function CoursesPage() {
|
||||||
Add your first sub-category
|
Add your first sub-category
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : filteredCourses.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center">
|
|
||||||
<p className="text-base font-semibold text-grayScale-600">No matching sub-categories</p>
|
|
||||||
<p className="mt-1.5 text-sm text-grayScale-400">
|
|
||||||
Try a different search term.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border bg-white">
|
<div className="rounded-xl border bg-white">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Sub-category</TableHead>
|
<TableHead className="w-[88px] whitespace-nowrap">ID</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Status</TableHead>
|
<TableHead className="min-w-[160px]">SUB-CATEGORY</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="hidden lg:table-cell min-w-[140px]">CATEGORY</TableHead>
|
||||||
|
<TableHead className="min-w-[220px]">DESCRIPTION</TableHead>
|
||||||
|
<TableHead className="hidden xl:table-cell w-[100px] whitespace-nowrap">ORDER</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell whitespace-nowrap">CATEGORY ID</TableHead>
|
||||||
|
<TableHead className="hidden xl:table-cell min-w-[140px] whitespace-nowrap">CREATED</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">STATUS</TableHead>
|
||||||
|
<TableHead className="text-right">ACTIONS</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{paginatedCourses.map((course) => (
|
{filteredSubCategories.length === 0 ? (
|
||||||
<TableRow
|
<TableRow>
|
||||||
key={course.id}
|
<TableCell colSpan={9} className="text-center py-12">
|
||||||
className="group cursor-pointer"
|
<div className="flex flex-col items-center gap-3">
|
||||||
onClick={() => handleCourseClick(course.id)}
|
<Search className="h-8 w-8 text-grayScale-200" />
|
||||||
>
|
<div>
|
||||||
<TableCell className="max-w-md py-3.5">
|
<p className="text-sm font-medium text-grayScale-500">No matching sub-categories</p>
|
||||||
<div className="truncate text-sm font-semibold text-grayScale-700">
|
<p className="mt-1 text-xs text-grayScale-400">Try a different search term.</p>
|
||||||
{course.title}
|
|
||||||
</div>
|
</div>
|
||||||
{course.description && (
|
|
||||||
<div className="mt-1 truncate text-xs text-grayScale-400">
|
|
||||||
{course.description}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden py-3.5 md:table-cell">
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
paginatedSubCategories.map((sub) => (
|
||||||
|
<TableRow
|
||||||
|
key={sub.id}
|
||||||
|
className="group cursor-pointer"
|
||||||
|
onClick={() => handleOpenSubCategory(sub.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="tabular-nums text-sm text-grayScale-500">{formatId(sub.id)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 transition-colors group-hover:bg-brand-500 group-hover:text-white">
|
||||||
|
<BookOpen className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<p className="min-w-0 text-sm font-medium text-grayScale-600">{sub.name}</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">
|
||||||
|
<span className="text-sm text-grayScale-600">{sub.category_name}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<p className="max-w-md truncate text-sm text-grayScale-600" title={sub.description || undefined}>
|
||||||
|
{sub.description?.trim() ? sub.description : "—"}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden xl:table-cell tabular-nums text-sm text-grayScale-600">
|
||||||
|
{sub.display_order}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
<span className="text-sm tabular-nums text-grayScale-600">{formatId(sub.category_id)}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden xl:table-cell text-sm text-grayScale-500">
|
||||||
|
{formatCreatedAt(sub.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant={course.is_active ? "success" : "secondary"}
|
variant={sub.is_active ? "success" : "secondary"}
|
||||||
className="text-[11px] font-semibold"
|
className="text-[11px] font-semibold"
|
||||||
>
|
>
|
||||||
{course.is_active ? "Active" : "Inactive"}
|
{sub.is_active ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-3.5 text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-amber-400 hover:bg-amber-50 hover:text-amber-500"
|
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleViewRatings(course.id)
|
handleOpenSubCategory(sub.id)
|
||||||
}}
|
}}
|
||||||
|
title="View courses"
|
||||||
>
|
>
|
||||||
<Star className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -435,7 +453,7 @@ export function CoursesPage() {
|
||||||
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
|
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleEditClick(course)
|
handleEditClick(sub)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
|
|
@ -444,13 +462,13 @@ export function CoursesPage() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
|
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
|
||||||
disabled={togglingId === course.id}
|
disabled={togglingId === sub.id}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleToggleStatus(course)
|
handleToggleStatus(sub)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{course.is_active ? (
|
{sub.is_active ? (
|
||||||
<ToggleLeft className="h-4 w-4" />
|
<ToggleLeft className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<ToggleRight className="h-4 w-4" />
|
<ToggleRight className="h-4 w-4" />
|
||||||
|
|
@ -462,7 +480,7 @@ export function CoursesPage() {
|
||||||
className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600"
|
className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleDeleteClick(course)
|
handleDeleteClick(sub)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|
@ -470,15 +488,17 @@ export function CoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
{totalCount > 0 ? (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span>Showing</span>
|
<span>Showing</span>
|
||||||
<span className="font-medium text-grayScale-600">
|
<span className="font-medium text-grayScale-600">
|
||||||
{startEntry}-{endEntry}
|
{startEntry}–{endEntry}
|
||||||
</span>
|
</span>
|
||||||
<span>of</span>
|
<span>of</span>
|
||||||
<span className="font-medium text-grayScale-600">{totalCount}</span>
|
<span className="font-medium text-grayScale-600">{totalCount}</span>
|
||||||
|
|
@ -499,11 +519,12 @@ export function CoursesPage() {
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
|
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => safePage > 1 && setPage(safePage - 1)}
|
onClick={() => safePage > 1 && setPage(safePage - 1)}
|
||||||
disabled={safePage === 1}
|
disabled={safePage === 1}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -535,6 +556,7 @@ export function CoursesPage() {
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => safePage < totalPages && setPage(safePage + 1)}
|
onClick={() => safePage < totalPages && setPage(safePage + 1)}
|
||||||
disabled={safePage === totalPages}
|
disabled={safePage === totalPages}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -546,12 +568,11 @@ export function CoursesPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Add Course Modal */}
|
{/* Add Sub-category Modal */}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="mx-4 w-full max-w-2xl animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
<div className="mx-4 w-full max-w-2xl animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
||||||
|
|
@ -575,14 +596,14 @@ export function CoursesPage() {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="course-title"
|
htmlFor="subcat-name"
|
||||||
className="mb-2 block text-sm font-medium text-grayScale-600"
|
className="mb-2 block text-sm font-medium text-grayScale-600"
|
||||||
>
|
>
|
||||||
Title <span className="text-red-400">*</span>
|
Name <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="course-title"
|
id="subcat-name"
|
||||||
placeholder="Enter sub-category title"
|
placeholder="Enter sub-category name"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -590,14 +611,14 @@ export function CoursesPage() {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="course-description"
|
htmlFor="subcat-description"
|
||||||
className="mb-2 block text-sm font-medium text-grayScale-600"
|
className="mb-2 block text-sm font-medium text-grayScale-600"
|
||||||
>
|
>
|
||||||
Description <span className="text-red-400">*</span>
|
Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="course-description"
|
id="subcat-description"
|
||||||
placeholder="Enter sub-category description"
|
placeholder="Optional description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
|
|
@ -605,6 +626,20 @@ export function CoursesPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="subcat-order" className="mb-2 block text-sm font-medium text-grayScale-600">
|
||||||
|
Display order
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="subcat-order"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
placeholder="0"
|
||||||
|
value={displayOrder}
|
||||||
|
onChange={(e) => setDisplayOrder(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400">
|
<div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400">
|
||||||
Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
|
Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -626,8 +661,8 @@ export function CoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Edit Course Modal */}
|
{/* Edit Sub-category Modal */}
|
||||||
{showEditModal && courseToEdit && (
|
{showEditModal && subCategoryToEdit && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="mx-4 w-full max-w-md animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
<div className="mx-4 w-full max-w-md animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
||||||
|
|
@ -650,14 +685,14 @@ export function CoursesPage() {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="edit-course-title"
|
htmlFor="edit-subcat-name"
|
||||||
className="mb-2 block text-sm font-medium text-grayScale-600"
|
className="mb-2 block text-sm font-medium text-grayScale-600"
|
||||||
>
|
>
|
||||||
Title <span className="text-red-400">*</span>
|
Name <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-course-title"
|
id="edit-subcat-name"
|
||||||
placeholder="Enter sub-category title"
|
placeholder="Enter sub-category name"
|
||||||
value={editTitle}
|
value={editTitle}
|
||||||
onChange={(e) => setEditTitle(e.target.value)}
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -665,14 +700,14 @@ export function CoursesPage() {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="edit-course-description"
|
htmlFor="edit-subcat-description"
|
||||||
className="mb-2 block text-sm font-medium text-grayScale-600"
|
className="mb-2 block text-sm font-medium text-grayScale-600"
|
||||||
>
|
>
|
||||||
Description <span className="text-red-400">*</span>
|
Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="edit-course-description"
|
id="edit-subcat-description"
|
||||||
placeholder="Enter sub-category description"
|
placeholder="Optional description"
|
||||||
value={editDescription}
|
value={editDescription}
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
|
|
@ -681,29 +716,18 @@ export function CoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="edit-subcat-order" className="mb-2 block text-sm font-medium text-grayScale-600">
|
||||||
htmlFor="edit-course-thumbnail"
|
Display order
|
||||||
className="mb-2 block text-sm font-medium text-grayScale-600"
|
|
||||||
>
|
|
||||||
Thumbnail
|
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
|
||||||
<FileUpload
|
|
||||||
accept="image/*"
|
|
||||||
onFileSelect={(file) => setEditThumbnailFile(file)}
|
|
||||||
label="Upload thumbnail"
|
|
||||||
description="JPEG, PNG, WEBP"
|
|
||||||
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
|
||||||
/>
|
|
||||||
<Input
|
<Input
|
||||||
id="edit-course-thumbnail"
|
id="edit-subcat-order"
|
||||||
placeholder="Or paste thumbnail URL (https://...)"
|
type="number"
|
||||||
value={editThumbnail}
|
min={0}
|
||||||
onChange={(e) => setEditThumbnail(e.target.value)}
|
value={editDisplayOrder}
|
||||||
|
onChange={(e) => setEditDisplayOrder(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||||
<Button variant="outline" onClick={handleCloseEditModal} disabled={updating} className="w-full sm:w-auto">
|
<Button variant="outline" onClick={handleCloseEditModal} disabled={updating} className="w-full sm:w-auto">
|
||||||
|
|
@ -721,122 +745,8 @@ export function CoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ratings Modal */}
|
{/* Delete Sub-category Modal */}
|
||||||
{showRatingsModal && (
|
{showDeleteModal && subCategoryToDelete && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
|
||||||
<div className="mx-4 w-full max-w-lg animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
|
||||||
<h2 className="text-lg font-bold text-grayScale-700">Sub-category Ratings</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowRatingsModal(false)
|
|
||||||
setRatingsCourseId(null)
|
|
||||||
setCourseRatings([])
|
|
||||||
}}
|
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-[70vh] overflow-y-auto px-6 py-6">
|
|
||||||
{courseRatingsLoading ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16">
|
|
||||||
<SpinnerIcon className="h-8 w-8" />
|
|
||||||
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading ratings…</p>
|
|
||||||
</div>
|
|
||||||
) : courseRatings.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-16">
|
|
||||||
<div className="rounded-full bg-amber-50 p-4">
|
|
||||||
<Star className="h-8 w-8 text-amber-400" />
|
|
||||||
</div>
|
|
||||||
<p className="mt-4 text-sm font-semibold text-grayScale-700">No ratings yet</p>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
|
||||||
Ratings will appear here once learners start reviewing this sub-category.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Summary bar */}
|
|
||||||
<div className="flex flex-wrap items-center gap-6 rounded-xl border border-grayScale-200 px-5 py-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Star className="h-5 w-5 text-amber-400" fill="currentColor" />
|
|
||||||
<span className="text-lg font-bold text-grayScale-800">
|
|
||||||
{(courseRatings.reduce((sum, r) => sum + r.stars, 0) / courseRatings.length).toFixed(1)}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-grayScale-400">/ 5</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-5 w-px bg-grayScale-200" />
|
|
||||||
<span className="text-sm text-grayScale-500">
|
|
||||||
{courseRatings.length} review{courseRatings.length !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rating cards */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{courseRatings.map((rating) => (
|
|
||||||
<div
|
|
||||||
key={rating.id}
|
|
||||||
className="rounded-xl border border-grayScale-200 bg-white p-5 space-y-3"
|
|
||||||
>
|
|
||||||
{/* Header row */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-50 text-sm font-bold text-brand-600">
|
|
||||||
U{rating.user_id}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-grayScale-700">User #{rating.user_id}</p>
|
|
||||||
<p className="text-[11px] text-grayScale-400">
|
|
||||||
{new Date(rating.created_at).toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
{rating.updated_at !== rating.created_at && (
|
|
||||||
<span className="ml-1.5 text-grayScale-300">· edited</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stars */}
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
{[1, 2, 3, 4, 5].map((s) => (
|
|
||||||
<Star
|
|
||||||
key={s}
|
|
||||||
className={`h-4 w-4 ${
|
|
||||||
s <= rating.stars
|
|
||||||
? "text-amber-400"
|
|
||||||
: "text-grayScale-200"
|
|
||||||
}`}
|
|
||||||
fill={s <= rating.stars ? "currentColor" : "none"}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Review text */}
|
|
||||||
{rating.review && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<MessageSquare className="mt-0.5 h-3.5 w-3.5 shrink-0 text-grayScale-300" />
|
|
||||||
<p className="text-sm leading-relaxed text-grayScale-600">
|
|
||||||
{rating.review}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete Course Modal */}
|
|
||||||
{showDeleteModal && courseToDelete && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
||||||
|
|
@ -855,7 +765,7 @@ export function CoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
||||||
Are you sure you want to delete{" "}
|
Are you sure you want to delete{" "}
|
||||||
<span className="font-semibold text-grayScale-700">{courseToDelete.title}</span>? This action cannot
|
<span className="font-semibold text-grayScale-700">{subCategoryToDelete.name}</span>? This action cannot
|
||||||
be undone.
|
be undone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
1310
src/pages/content-management/HumanLanguageHierarchyPage.tsx
Normal file
1310
src/pages/content-management/HumanLanguageHierarchyPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -58,7 +58,13 @@ const typeColors: Record<QuestionType, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PracticeQuestionsPage() {
|
export function PracticeQuestionsPage() {
|
||||||
const { categoryId, courseId, subModuleId, practiceId } = useParams()
|
const { categoryId, courseId, subModuleId, levelId, practiceId } = useParams<{
|
||||||
|
categoryId: string
|
||||||
|
courseId: string
|
||||||
|
subModuleId?: string
|
||||||
|
levelId?: string
|
||||||
|
practiceId?: string
|
||||||
|
}>()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
const [questions, setQuestions] = useState<PracticeQuestion[]>([])
|
const [questions, setQuestions] = useState<PracticeQuestion[]>([])
|
||||||
|
|
@ -102,11 +108,14 @@ export function PracticeQuestionsPage() {
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
|
||||||
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("/level/") && levelId) {
|
||||||
|
return "/content/human-language"
|
||||||
|
}
|
||||||
|
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/") && subModuleId) {
|
||||||
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
|
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
|
||||||
}
|
}
|
||||||
return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`
|
return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`
|
||||||
}, [location.pathname, categoryId, courseId, subModuleId])
|
}, [location.pathname, categoryId, courseId, subModuleId, levelId])
|
||||||
|
|
||||||
const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => {
|
const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => {
|
||||||
if (type === "TRUE_FALSE") {
|
if (type === "TRUE_FALSE") {
|
||||||
|
|
|
||||||
144
src/pages/content-management/SubCategoryCoursesPage.tsx
Normal file
144
src/pages/content-management/SubCategoryCoursesPage.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom"
|
||||||
|
import { ArrowLeft, BookOpen, ChevronRight } from "lucide-react"
|
||||||
|
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||||
|
import alertSrc from "../../assets/Alert.svg"
|
||||||
|
import { Badge } from "../../components/ui/badge"
|
||||||
|
import { getCoursesBySubCategoryId, getSubCategoriesByCategoryId } from "../../api/courses.api"
|
||||||
|
import type { CategorySubCategoryListItem, SubCategoryCourseListItem } from "../../types/course.types"
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
export function SubCategoryCoursesPage() {
|
||||||
|
const { categoryId, subCategoryId } = useParams<{
|
||||||
|
categoryId: string
|
||||||
|
subCategoryId: string
|
||||||
|
}>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [subCategory, setSubCategory] = useState<CategorySubCategoryListItem | null>(null)
|
||||||
|
const [courses, setCourses] = useState<SubCategoryCourseListItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const run = async () => {
|
||||||
|
if (!categoryId || !subCategoryId) return
|
||||||
|
const cid = Number(categoryId)
|
||||||
|
const sid = Number(subCategoryId)
|
||||||
|
if (!Number.isFinite(cid) || !Number.isFinite(sid)) {
|
||||||
|
setError("Invalid route parameters")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const [subRes, coursesRes] = await Promise.all([
|
||||||
|
getSubCategoriesByCategoryId(cid),
|
||||||
|
getCoursesBySubCategoryId(sid),
|
||||||
|
])
|
||||||
|
const list = subRes.data?.data?.sub_categories ?? []
|
||||||
|
const found = Array.isArray(list) ? list.find((s) => s.id === sid) : undefined
|
||||||
|
setSubCategory(found ?? null)
|
||||||
|
|
||||||
|
const raw = coursesRes.data?.data?.courses
|
||||||
|
setCourses(Array.isArray(raw) ? raw : [])
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
setError("Failed to load courses for this sub-category")
|
||||||
|
setCourses([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void run()
|
||||||
|
}, [categoryId, subCategoryId])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
|
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||||
|
<p className="mt-4 text-sm text-grayScale-500">Loading courses…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-32">
|
||||||
|
<div className="mx-4 flex w-full max-w-md items-center gap-3 rounded-2xl border border-red-100 bg-red-50 px-6 py-5 shadow-sm">
|
||||||
|
<img src={alertSrc} alt="" className="h-10 w-10 shrink-0" />
|
||||||
|
<p className="text-sm font-medium text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = subCategory?.name ?? "Sub-category"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="rounded-2xl border border-grayScale-100 bg-white px-5 py-4 shadow-sm sm:px-6 sm:py-5">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="flex items-start gap-3.5">
|
||||||
|
<Link
|
||||||
|
to={`/content/category/${categoryId}/courses`}
|
||||||
|
className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-grayScale-50 text-grayScale-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-grayScale-400">Sub-category</p>
|
||||||
|
<h1 className="text-xl font-bold text-grayScale-700 sm:text-2xl">{label}</h1>
|
||||||
|
<p className="mt-0.5 text-sm text-grayScale-400">
|
||||||
|
{courses.length} course{courses.length !== 1 ? "s" : ""} — open a course to manage sub-modules
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{courses.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||||
|
<p className="text-sm font-medium text-grayScale-600">No courses in this sub-category yet</p>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-400">Add a course from your authoring flow or API.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{courses.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/content/category/${categoryId}/courses/${c.id}/sub-modules`)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between gap-4 rounded-xl border border-grayScale-200 bg-white px-4 py-4 text-left shadow-sm transition-all",
|
||||||
|
"hover:border-brand-200 hover:bg-brand-50/40 hover:shadow-md",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400">
|
||||||
|
<BookOpen className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold text-grayScale-800">{c.title}</p>
|
||||||
|
{c.description?.trim() ? (
|
||||||
|
<p className="mt-0.5 line-clamp-2 text-sm text-grayScale-500">{c.description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-3">
|
||||||
|
<Badge variant={c.is_active ? "success" : "secondary"} className="text-[11px]">
|
||||||
|
{c.is_active ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
<ChevronRight className="h-5 w-5 text-grayScale-300" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -103,9 +103,10 @@ export function SubModuleContentPage() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subCoursesRes = await getSubModulesByCourse(Number(courseId))
|
const subCoursesRes = await getSubModulesByCourse(Number(courseId))
|
||||||
const foundSubCourse = subCoursesRes.data.data.sub_courses?.find(
|
const list = subCoursesRes.data?.data?.sub_courses
|
||||||
(sc) => sc.id === Number(subModuleId)
|
const foundSubCourse = Array.isArray(list)
|
||||||
)
|
? list.find((sc) => sc.id === Number(subModuleId))
|
||||||
|
: undefined
|
||||||
setSubCourse(foundSubCourse ?? null)
|
setSubCourse(foundSubCourse ?? null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch course data:", err)
|
console.error("Failed to fetch course data:", err)
|
||||||
|
|
@ -123,7 +124,9 @@ export function SubModuleContentPage() {
|
||||||
setPracticesLoading(true)
|
setPracticesLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getQuestionSetsByOwner("SUB_MODULE", Number(subModuleId))
|
const res = await getQuestionSetsByOwner("SUB_MODULE", Number(subModuleId))
|
||||||
setPractices(res.data.data ?? [])
|
const raw = res.data?.data
|
||||||
|
const list = Array.isArray(raw) ? raw : (raw as { question_sets?: QuestionSet[] })?.question_sets ?? []
|
||||||
|
setPractices(Array.isArray(list) ? list : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch practices:", err)
|
console.error("Failed to fetch practices:", err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -136,7 +139,8 @@ export function SubModuleContentPage() {
|
||||||
setVideosLoading(true)
|
setVideosLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getVideosBySubModule(Number(subModuleId))
|
const res = await getVideosBySubModule(Number(subModuleId))
|
||||||
setVideos(res.data.data.videos ?? [])
|
const vids = res.data?.data?.videos ?? []
|
||||||
|
setVideos(Array.isArray(vids) ? vids : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch videos:", err)
|
console.error("Failed to fetch videos:", err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -154,7 +158,7 @@ export function SubModuleContentPage() {
|
||||||
limit: ratingsPageSize,
|
limit: ratingsPageSize,
|
||||||
offset,
|
offset,
|
||||||
})
|
})
|
||||||
setRatings(res.data.data ?? [])
|
setRatings(res.data?.data ?? [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch ratings:", err)
|
console.error("Failed to fetch ratings:", err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -405,8 +409,8 @@ export function SubModuleContentPage() {
|
||||||
const idMatch = video.video_url?.match(/(\d{5,})/)
|
const idMatch = video.video_url?.match(/(\d{5,})/)
|
||||||
const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
|
const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
|
||||||
const res = await getVimeoSample(vimeoId)
|
const res = await getVimeoSample(vimeoId)
|
||||||
setPreviewIframe(res.data.data.iframe)
|
setPreviewIframe(res.data?.data?.iframe ?? "")
|
||||||
setPreviewVideo(res.data.data.video)
|
setPreviewVideo(res.data?.data?.video ?? null)
|
||||||
} catch {
|
} catch {
|
||||||
setPreviewIframe("")
|
setPreviewIframe("")
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -414,7 +418,7 @@ export function SubModuleContentPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredPractices = practices.filter((practice) => {
|
const filteredPractices = (Array.isArray(practices) ? practices : []).filter((practice) => {
|
||||||
if (statusFilter === "all") return true
|
if (statusFilter === "all") return true
|
||||||
if (statusFilter === "published") return practice.status === "PUBLISHED"
|
if (statusFilter === "published") return practice.status === "PUBLISHED"
|
||||||
if (statusFilter === "draft") return practice.status === "DRAFT"
|
if (statusFilter === "draft") return practice.status === "DRAFT"
|
||||||
|
|
@ -440,6 +444,19 @@ export function SubModuleContentPage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!subCourse) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<img src={alertSrc} alt="" className="h-12 w-12" />
|
||||||
|
<p className="mt-3 text-sm font-medium text-grayScale-600">Sub-module not found</p>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-400">It may have been removed or the link is invalid.</p>
|
||||||
|
<Button className="mt-6" variant="outline" asChild>
|
||||||
|
<Link to={`/content/category/${categoryId}/courses/${courseId}/sub-modules`}>Back to sub-modules</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
|
|
@ -590,7 +607,7 @@ export function SubModuleContentPage() {
|
||||||
<div className="flex items-center gap-3 text-xs text-grayScale-400">
|
<div className="flex items-center gap-3 text-xs text-grayScale-400">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Layers className="h-3.5 w-3.5" />
|
<Layers className="h-3.5 w-3.5" />
|
||||||
<span>{practice.owner_type.replace("_", " ")}</span>
|
<span>{String(practice.owner_type ?? "SUB_MODULE").replace(/_/g, " ")}</span>
|
||||||
</div>
|
</div>
|
||||||
{practice.shuffle_questions && (
|
{practice.shuffle_questions && (
|
||||||
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-600 ring-1 ring-inset ring-amber-200">Shuffle ON</span>
|
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-600 ring-1 ring-inset ring-amber-200">Shuffle ON</span>
|
||||||
|
|
@ -599,11 +616,13 @@ export function SubModuleContentPage() {
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between border-t border-grayScale-100 pt-3">
|
<div className="mt-auto flex items-center justify-between border-t border-grayScale-100 pt-3">
|
||||||
<span className="text-xs font-medium text-grayScale-400">
|
<span className="text-xs font-medium text-grayScale-400">
|
||||||
{new Date(practice.created_at).toLocaleDateString("en-US", {
|
{practice.created_at
|
||||||
|
? new Date(practice.created_at).toLocaleDateString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
})}
|
})
|
||||||
|
: "—"}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
|
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -95,12 +95,13 @@ function getStatusConfig(status: string): {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIssueTypeConfig(type: string): {
|
function getIssueTypeConfig(type: string | null | undefined): {
|
||||||
label: string;
|
label: string;
|
||||||
classes: string;
|
classes: string;
|
||||||
icon: typeof Bug;
|
icon: typeof Bug;
|
||||||
} {
|
} {
|
||||||
switch (type) {
|
const t = String(type ?? "").trim();
|
||||||
|
switch (t) {
|
||||||
case "bug":
|
case "bug":
|
||||||
return {
|
return {
|
||||||
label: "Bug",
|
label: "Bug",
|
||||||
|
|
@ -133,7 +134,7 @@ function getIssueTypeConfig(type: string): {
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
label: type.charAt(0).toUpperCase() + type.slice(1),
|
label: t ? t.charAt(0).toUpperCase() + t.slice(1) : "Other",
|
||||||
classes: "bg-grayScale-100 text-grayScale-600 border-grayScale-200",
|
classes: "bg-grayScale-100 text-grayScale-600 border-grayScale-200",
|
||||||
icon: HelpCircle,
|
icon: HelpCircle,
|
||||||
};
|
};
|
||||||
|
|
@ -173,8 +174,10 @@ function getRelativeTime(dateStr: string): string {
|
||||||
return formatDate(dateStr);
|
return formatDate(dateStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRoleLabel(role: string): string {
|
function formatRoleLabel(role: string | null | undefined): string {
|
||||||
return role
|
const r = String(role ?? "").trim();
|
||||||
|
if (!r) return "—";
|
||||||
|
return r
|
||||||
.split("_")
|
.split("_")
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
@ -221,8 +224,9 @@ export function IssuesPage() {
|
||||||
offset: (page - 1) * pageSize,
|
offset: (page - 1) * pageSize,
|
||||||
};
|
};
|
||||||
const res = await getIssues(filters);
|
const res = await getIssues(filters);
|
||||||
setIssues(res.data.data.issues);
|
const payload = res.data?.data;
|
||||||
setTotalCount(res.data.data.total_count);
|
setIssues(Array.isArray(payload?.issues) ? payload.issues : []);
|
||||||
|
setTotalCount(typeof payload?.total_count === "number" ? payload.total_count : 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch issues:", error);
|
console.error("Failed to fetch issues:", error);
|
||||||
setIssues([]);
|
setIssues([]);
|
||||||
|
|
@ -241,7 +245,7 @@ export function IssuesPage() {
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getIssueById(issueId);
|
const res = await getIssueById(issueId);
|
||||||
setSelectedIssue(res.data.data);
|
setSelectedIssue(res.data?.data ?? null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch issue detail:", error);
|
console.error("Failed to fetch issue detail:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -305,16 +309,15 @@ export function IssuesPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Client-side filtering (status, type, search)
|
// Client-side filtering (status, type, search)
|
||||||
const filteredIssues = issues.filter((issue) => {
|
const filteredIssues = (Array.isArray(issues) ? issues : []).filter((issue) => {
|
||||||
if (statusFilter && issue.status !== statusFilter) return false;
|
if (statusFilter && issue.status !== statusFilter) return false;
|
||||||
if (typeFilter && issue.issue_type !== typeFilter) return false;
|
if (typeFilter && issue.issue_type !== typeFilter) return false;
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
return (
|
const subject = String(issue.subject ?? "").toLowerCase();
|
||||||
issue.subject.toLowerCase().includes(q) ||
|
const description = String(issue.description ?? "").toLowerCase();
|
||||||
issue.description.toLowerCase().includes(q) ||
|
const issueType = String(issue.issue_type ?? "").toLowerCase();
|
||||||
issue.issue_type.toLowerCase().includes(q)
|
return subject.includes(q) || description.includes(q) || issueType.includes(q);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
@ -537,10 +540,10 @@ export function IssuesPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium text-grayScale-600 truncate">
|
<p className="text-sm font-medium text-grayScale-600 truncate">
|
||||||
{issue.subject}
|
{issue.subject?.trim() ? issue.subject : "—"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-grayScale-400 truncate mt-0.5">
|
<p className="text-xs text-grayScale-400 truncate mt-0.5">
|
||||||
{issue.description}
|
{issue.description?.trim() ? issue.description : "No description"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -572,6 +575,9 @@ export function IssuesPage() {
|
||||||
{getStatusConfig(s).label}
|
{getStatusConfig(s).label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
{!STATUSES.includes(issue.status as (typeof STATUSES)[number]) && issue.status ? (
|
||||||
|
<option value={issue.status}>{getStatusConfig(issue.status).label}</option>
|
||||||
|
) : null}
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 pointer-events-none opacity-50" />
|
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 pointer-events-none opacity-50" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,13 @@ export interface GetModulesResponse {
|
||||||
export interface CreateModuleRequest {
|
export interface CreateModuleRequest {
|
||||||
level_id: number
|
level_id: number
|
||||||
title: string
|
title: string
|
||||||
content: string
|
/** Legacy field kept for backward compatibility. */
|
||||||
|
content?: string
|
||||||
|
/** Preferred field for module detail text. */
|
||||||
|
description?: string
|
||||||
|
icon_url?: string
|
||||||
|
display_order?: number
|
||||||
|
is_active?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated Use UpdateSubCourseRequest instead */
|
/** @deprecated Use UpdateSubCourseRequest instead */
|
||||||
|
|
@ -193,6 +199,8 @@ export interface UpdateModuleStatusRequest {
|
||||||
export interface SubCourse {
|
export interface SubCourse {
|
||||||
id: number
|
id: number
|
||||||
course_id: number
|
course_id: number
|
||||||
|
/** Present when derived from course hierarchy rows (levels → modules → sub-modules). */
|
||||||
|
level_id?: number
|
||||||
module_id?: number
|
module_id?: number
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
|
|
@ -705,15 +713,31 @@ export interface HumanLanguageLesson {
|
||||||
export interface SubModuleLessonDetail {
|
export interface SubModuleLessonDetail {
|
||||||
id: number
|
id: number
|
||||||
sub_module_id: number
|
sub_module_id: number
|
||||||
question_set_id: number
|
|
||||||
intro_video_url?: string | null
|
|
||||||
display_order: number
|
display_order: number
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
title: string
|
title: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
status: string
|
thumbnail?: string | null
|
||||||
set_type: string
|
teaching_text?: string | null
|
||||||
question_count: number
|
teaching_image_url?: string | null
|
||||||
|
teaching_audio_url?: string | null
|
||||||
|
teaching_video_url?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubModuleLesson {
|
||||||
|
id: number
|
||||||
|
sub_module_id: number
|
||||||
|
display_order: number
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
title: string
|
||||||
|
description?: string | null
|
||||||
|
thumbnail?: string | null
|
||||||
|
teaching_text?: string | null
|
||||||
|
teaching_image_url?: string | null
|
||||||
|
teaching_audio_url?: string | null
|
||||||
|
teaching_video_url?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetSubModuleLessonDetailResponse {
|
export interface GetSubModuleLessonDetailResponse {
|
||||||
|
|
@ -724,6 +748,34 @@ export interface GetSubModuleLessonDetailResponse {
|
||||||
metadata: unknown
|
metadata: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateSubModuleLessonRequest {
|
||||||
|
title: string
|
||||||
|
description?: string | null
|
||||||
|
thumbnail?: string | null
|
||||||
|
teaching_text?: string | null
|
||||||
|
teaching_image_url?: string | null
|
||||||
|
teaching_audio_url?: string | null
|
||||||
|
teaching_video_url?: string | null
|
||||||
|
display_order: number
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSubModuleLessonResponse {
|
||||||
|
message: string
|
||||||
|
data: SubModuleLessonDetail
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetSubModuleLessonsResponse {
|
||||||
|
message: string
|
||||||
|
data: SubModuleLesson[]
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
export interface GetHumanLanguageLessonsResponse {
|
export interface GetHumanLanguageLessonsResponse {
|
||||||
message: string
|
message: string
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -737,6 +789,196 @@ export interface GetHumanLanguageLessonsResponse {
|
||||||
metadata: unknown
|
metadata: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Row from GET /course-management/human-language/sub-categories */
|
||||||
|
export interface HumanLanguageSubCategoryListItem {
|
||||||
|
id: number
|
||||||
|
category_id: number
|
||||||
|
category_name: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
display_order: number
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
/** Present on some payloads; ignore if unused. */
|
||||||
|
total_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetHumanLanguageSubCategoriesResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
sub_categories: HumanLanguageSubCategoryListItem[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row from GET /course-management/categories/:categoryId/sub-categories */
|
||||||
|
export interface CategorySubCategoryListItem {
|
||||||
|
id: number
|
||||||
|
category_id: number
|
||||||
|
category_name: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
display_order: number
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
/** Sometimes echoed per row by the API; safe to ignore. */
|
||||||
|
total_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCategorySubCategoriesResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
sub_categories: CategorySubCategoryListItem[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row from GET /course-management/sub-categories/:subCategoryId/courses */
|
||||||
|
export interface SubCategoryCourseListItem {
|
||||||
|
id: number
|
||||||
|
category_id: number
|
||||||
|
sub_category_id: number
|
||||||
|
title: string
|
||||||
|
description?: string | null
|
||||||
|
thumbnail?: string | null
|
||||||
|
intro_video_url?: string | null
|
||||||
|
is_active: boolean
|
||||||
|
total_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetSubCategoryCoursesResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
courses: SubCategoryCourseListItem[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row from GET /course-management/courses/:courseId/levels or GET /course-management/levels */
|
||||||
|
export interface CourseLevelRow {
|
||||||
|
id: number
|
||||||
|
course_id: number
|
||||||
|
cefr_level: string
|
||||||
|
display_order: number
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
title: string
|
||||||
|
description?: string | null
|
||||||
|
thumbnail?: string | null
|
||||||
|
total_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCourseLevelsForCourseResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
levels: CourseLevelRow[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCourseLevelsAllResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
levels: CourseLevelRow[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCourseLevelByIdResponse {
|
||||||
|
message: string
|
||||||
|
data: CourseLevelRow
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row from GET /course-management/modules/:moduleId/sub-modules */
|
||||||
|
export interface CourseSubModuleListItem {
|
||||||
|
id: number
|
||||||
|
module_id: number
|
||||||
|
title: string
|
||||||
|
description?: string | null
|
||||||
|
display_order: number
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
legacy_sub_course_id?: number | null
|
||||||
|
thumbnail?: string | null
|
||||||
|
tips?: string | null
|
||||||
|
total_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetSubModulesByModuleResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
sub_modules: CourseSubModuleListItem[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row from GET /course-management/human-language/hierarchy */
|
||||||
|
export interface HumanLanguageHierarchyFlatRow {
|
||||||
|
category_id: number
|
||||||
|
category_name: string
|
||||||
|
sub_category_id?: number | null
|
||||||
|
sub_category_name?: string | null
|
||||||
|
course_id?: number | null
|
||||||
|
course_title?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetHumanLanguageHierarchyFlatResponse {
|
||||||
|
message: string
|
||||||
|
data: HumanLanguageHierarchyFlatRow[]
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row from GET /course-management/courses/:courseId/hierarchy */
|
||||||
|
export interface CourseHierarchyRow {
|
||||||
|
course_id: number
|
||||||
|
course_title: string
|
||||||
|
level_id?: number | null
|
||||||
|
cefr_level?: string | null
|
||||||
|
level_title?: string | null
|
||||||
|
level_description?: string | null
|
||||||
|
level_thumbnail?: string | null
|
||||||
|
module_id?: number | null
|
||||||
|
module_title?: string | null
|
||||||
|
module_icon_url?: string | null
|
||||||
|
sub_module_id?: number | null
|
||||||
|
sub_module_title?: string | null
|
||||||
|
sub_module_description?: string | null
|
||||||
|
sub_module_thumbnail?: string | null
|
||||||
|
sub_module_tips?: string | null
|
||||||
|
sub_module_display_order?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCourseHierarchyResponse {
|
||||||
|
message: string
|
||||||
|
data: CourseHierarchyRow[]
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
export interface HumanLanguageSubModule {
|
export interface HumanLanguageSubModule {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user