diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index d0f3cf8..ec88619 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -49,7 +49,18 @@ import type { GetLearningPathResponse, GetSubModuleLessonDetailResponse, GetHumanLanguageLessonsResponse, - GetHumanLanguageHierarchyResponse, + GetSubModuleLessonsResponse, + GetHumanLanguageSubCategoriesResponse, + GetCategorySubCategoriesResponse, + GetSubCategoryCoursesResponse, + GetCourseLevelsForCourseResponse, + GetCourseLevelsAllResponse, + GetCourseLevelByIdResponse, + GetHumanLanguageHierarchyFlatResponse, + GetCourseHierarchyResponse, + GetSubModulesByModuleResponse, + CourseHierarchyRow, + SubCourse, CreateHumanLanguageLessonRequest, GetSubCourseEntryAssessmentResponse, ReorderItem, @@ -57,6 +68,8 @@ import type { GetRatingsParams, GetVimeoSampleResponse, CreateCourseVideoRequest, + UpdateSubModuleLessonRequest, + UpdateSubModuleLessonResponse, } from "../types/course.types" type UnifiedHierarchyRow = { @@ -68,17 +81,6 @@ type UnifiedHierarchyRow = { 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(request: () => Promise, retryDelayMs = 400): Promise { try { return await request() @@ -169,6 +171,26 @@ export const deleteCourseCategory = (categoryId: number) => export const deleteCourseSubCategory = (subCategoryId: number) => http.delete(`/course-management/sub-categories/${subCategoryId}`) +export const getSubCategoriesByCategoryId = (categoryId: number) => + http.get(`/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) => withSingleRetry(() => http.get("/course-management/hierarchy")).then((res) => { const rows: UnifiedHierarchyRow[] = res.data?.data ?? [] @@ -221,17 +243,39 @@ export const updateCourseStatus = (courseId: number, isActive: boolean) => export const updateCourse = (courseId: number, data: UpdateCourseRequest) => http.put(`/course-management/courses/${courseId}`, data) +export const getCourseHierarchyByCourseId = (courseId: number) => + http.get(`/course-management/courses/${courseId}/hierarchy`) + // Sub-Module APIs (Unified Hierarchy) export const getSubModulesByCourse = (courseId: number) => - http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => { - const rows: CourseHierarchyRow[] = res.data?.data ?? [] - const subModuleMap = new Map() + getCourseHierarchyByCourseId(courseId).then((res) => { + const raw = res.data?.data + 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) => { 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, { id: r.sub_module_id, course_id: courseId, + level_id: r.level_id ?? undefined, module_id: r.module_id ?? undefined, title: r.sub_module_title ?? "", description: "", @@ -242,7 +286,17 @@ export const getSubModulesByCourse = (courseId: number) => sub_level: r.cefr_level ?? undefined, 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()) return { @@ -299,6 +353,11 @@ export const deleteSubModule = (subModuleId: number) => export const getVideosBySubModule = (subModuleId: number) => http.get(`/course-management/sub-modules/${subModuleId}/videos`) +export const getLessonsBySubModule = (subModuleId: number, options?: { includeInactive?: boolean }) => + http.get(`/course-management/sub-modules/${subModuleId}/lessons`, { + params: { include_inactive: options?.includeInactive ?? true }, + }) + export const getSubModuleLessonById = ( lessonId: number, options?: { @@ -313,6 +372,14 @@ export const getSubModuleLessonById = ( params: options?.cacheBust ? { _t: Date.now() } : undefined, }) +export const updateSubModuleLesson = (lessonId: number, data: UpdateSubModuleLessonRequest) => + http.put(`/course-management/sub-module-lessons/${lessonId}`, data) + +export const softDeleteSubModuleLesson = (lessonId: number) => + http.put(`/course-management/sub-module-lessons/${lessonId}`, { + is_active: false, + }) + export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) => http.post("/course-management/sub-module-videos", { sub_module_id: data.sub_module_id ?? data.sub_course_id, @@ -631,270 +698,92 @@ export const getHumanLanguageLessonsByCourse = (courseId: number, cefr_level: st params: { cefr_level }, }) +export const getHumanLanguageSubCategories = () => + http.get("/course-management/human-language/sub-categories") + +export const getCoursesBySubCategoryId = (subCategoryId: number) => + http.get(`/course-management/sub-categories/${subCategoryId}/courses`) + +export const getSubModulesByModuleId = (moduleId: number) => + http.get(`/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 { + 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(`/course-management/courses/${courseId}/levels`) + +export const getAllCourseLevels = () => http.get("/course-management/levels") + +export const getCourseLevelById = (levelId: number) => + http.get(`/course-management/levels/${levelId}`) + export const getHumanLanguageHierarchy = (options?: { cacheBust?: boolean }) => withSingleRetry(() => - http.get("/course-management/hierarchy", { + http.get("/course-management/human-language/hierarchy", { 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( - 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 - } - > - } - >() - - ;(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() - await Promise.all( - subModuleIds.map(async (subModuleID) => { - try { - const questionSetRes = await http.get("/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) => http diff --git a/src/api/http.ts b/src/api/http.ts index 5ba1228..893c181 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -12,6 +12,7 @@ let failedQueue: Array<{ resolve: (token: string) => void; reject: (error: Error) => void; }> = []; +const TOKEN_REFRESH_BUFFER_SECONDS = 120; const processQueue = (error: Error | null, token: string | null = null) => { failedQueue.forEach((prom) => { @@ -32,23 +33,47 @@ const clearAuthAndRedirect = () => { window.location.href = "/login"; }; -const refreshAccessToken = async (): Promise => { - const accessToken = localStorage.getItem("access_token"); - const refreshToken = localStorage.getItem("refresh_token"); - const role = localStorage.getItem("role"); - const memberId = localStorage.getItem("member_id"); +const decodeJwtPayload = (token: string): Record | null => { + try { + const payloadPart = token.split(".")[1]; + if (!payloadPart) return null; + 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; + } 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 => { + const refreshToken = localStorage.getItem("refresh_token"); + + if (!refreshToken) { throw new Error("No refresh token available"); } 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, - role: role || "admin", - member_id: Number(memberId), } ); @@ -65,9 +90,43 @@ const refreshAccessToken = async (): Promise => { return newAccessToken; }; +const getValidAccessToken = async (forceRefresh = false): Promise => { + 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 -http.interceptors.request.use((config) => { - const token = localStorage.getItem("access_token"); +http.interceptors.request.use(async (config) => { + if (isAuthEndpointRequest(config.url)) { + return config; + } + + let token = localStorage.getItem("access_token"); + if (token && isAccessTokenExpiringSoon(token)) { + token = await getValidAccessToken(); + } + if (token) { config.headers.Authorization = `Bearer ${token}`; } @@ -80,32 +139,19 @@ http.interceptors.response.use( async (error: AxiosError) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; - if (error.response?.status === 401 && !originalRequest._retry) { - if (isRefreshing) { - return new Promise((resolve, reject) => { - failedQueue.push({ resolve, reject }); - }) - .then((token) => { - originalRequest.headers.Authorization = `Bearer ${token}`; - return http(originalRequest); - }) - .catch((err) => Promise.reject(err)); - } - + if ( + error.response?.status === 401 && + !originalRequest._retry && + !isAuthEndpointRequest(originalRequest.url) + ) { originalRequest._retry = true; - isRefreshing = true; try { - const newToken = await refreshAccessToken(); - processQueue(null, newToken); + const newToken = await getValidAccessToken(true); originalRequest.headers.Authorization = `Bearer ${newToken}`; return http(originalRequest); } catch (refreshError) { - processQueue(refreshError as Error, null); - clearAuthAndRedirect(); return Promise.reject(refreshError); - } finally { - isRefreshing = false; } } diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index 5903160..d14f85d 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -12,7 +12,6 @@ import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuest import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage" import { AddNewLessonPage } from "../pages/content-management/AddNewLessonPage" import { SubModulesPage } from "../pages/content-management/SubCoursesPage" -import { SubModuleContentPage } from "../pages/content-management/SubCourseContentPage" import { SpeakingPage } from "../pages/content-management/SpeakingPage" import { AddVideoPage } from "../pages/content-management/AddVideoPage" 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 { QuestionsPage } from "../pages/content-management/QuestionsPage" 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 { SubCategoryCoursesPage } from "../pages/content-management/SubCategoryCoursesPage" import { UserLogPage } from "../pages/user-log/UserLogPage" import { IssuesPage } from "../pages/issues/IssuesPage" import { ProfilePage } from "../pages/ProfilePage" @@ -79,7 +79,7 @@ export function AppRoutes() { } /> } /> } /> - } /> + } /> } @@ -92,21 +92,29 @@ export function AppRoutes() { path="human-language/:categoryId/:courseId/sub-module/:subModuleId/practices/:practiceId/questions" element={} /> + } + /> } /> } /> + } + /> } /> {/* Course → Sub-module → Lesson/Practice */} } /> - } /> + } /> } /> } /> } /> {/* Legacy aliases */} } /> - } /> + } /> } /> } /> } /> diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx index b71588b..ce84565 100644 --- a/src/layouts/AppLayout.tsx +++ b/src/layouts/AppLayout.tsx @@ -13,9 +13,6 @@ export function AppLayout() { const routeKey = useMemo(() => `${location.pathname}${location.search}`, [location.pathname, location.search]) const token = localStorage.getItem("access_token") - if (!token) { - return - } const handleSidebarToggle = useCallback(() => { setSidebarOpen((prev) => !prev) @@ -58,6 +55,10 @@ export function AppLayout() { } }, [routeKey]) + if (!token) { + return + } + return (
() const navigate = useNavigate() - const [courses, setCourses] = useState([]) + const [subCategories, setSubCategories] = useState([]) const [searchQuery, setSearchQuery] = useState("") const [category, setCategory] = useState(null) const [loading, setLoading] = useState(true) @@ -44,36 +52,32 @@ export function CoursesPage() { const [showModal, setShowModal] = useState(false) const [title, setTitle] = useState("") const [description, setDescription] = useState("") + const [displayOrder, setDisplayOrder] = useState("") const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(null) const [showDeleteModal, setShowDeleteModal] = useState(false) - const [courseToDelete, setCourseToDelete] = useState(null) + const [subCategoryToDelete, setSubCategoryToDelete] = useState(null) const [deleting, setDeleting] = useState(false) const [togglingId, setTogglingId] = useState(null) const [showEditModal, setShowEditModal] = useState(false) - const [courseToEdit, setCourseToEdit] = useState(null) + const [subCategoryToEdit, setSubCategoryToEdit] = useState(null) const [editTitle, setEditTitle] = useState("") const [editDescription, setEditDescription] = useState("") - const [editThumbnail, setEditThumbnail] = useState("") - const [editThumbnailFile, setEditThumbnailFile] = useState(null) + const [editDisplayOrder, setEditDisplayOrder] = useState(0) const [updating, setUpdating] = useState(false) const [updateError, setUpdateError] = useState(null) - const [showRatingsModal, setShowRatingsModal] = useState(false) - const [ratingsCourseId, setRatingsCourseId] = useState(null) - const [courseRatings, setCourseRatings] = useState([]) - const [courseRatingsLoading, setCourseRatingsLoading] = useState(false) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(10) - const fetchCourses = async () => { + const fetchSubCategories = async () => { if (!categoryId) return try { - const coursesRes = await getCoursesByCategory(Number(categoryId)) - console.log("Courses response:", coursesRes.data.data.courses) - setCourses(coursesRes.data.data.courses ?? []) + const res = await getSubCategoriesByCategoryId(Number(categoryId)) + const raw = res.data?.data?.sub_categories + setSubCategories(Array.isArray(raw) ? raw : []) } 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 try { - const [coursesRes, categoriesRes] = await Promise.all([ - getCoursesByCategory(Number(categoryId)), + const [subRes, categoriesRes] = await Promise.all([ + getSubCategoriesByCategoryId(Number(categoryId)), getCourseCategories(), ]) - setCourses(coursesRes.data.data.courses ?? []) - const foundCategory = categoriesRes.data.data.categories.find( - (c) => c.id === Number(categoryId) + const raw = subRes.data?.data?.sub_categories + setSubCategories(Array.isArray(raw) ? raw : []) + const foundCategory = categoriesRes.data?.data?.categories?.find( + (c) => c.id === Number(categoryId), ) setCategory(foundCategory ?? null) } catch (err) { - console.error("Failed to fetch courses:", err) + console.error("Failed to fetch sub-categories:", err) setError("Failed to load sub-categories") } finally { setLoading(false) @@ -110,6 +115,7 @@ export function CoursesPage() { const handleOpenModal = () => { setTitle("") setDescription("") + setDisplayOrder("") setSaveError(null) setShowModal(true) } @@ -118,16 +124,13 @@ export function CoursesPage() { setShowModal(false) setTitle("") setDescription("") + setDisplayOrder("") setSaveError(null) } const handleSave = async () => { if (!title.trim()) { - setSaveError("Title is required") - return - } - if (!description.trim()) { - setSaveError("Description is required") + setSaveError("Name is required") return } @@ -135,13 +138,15 @@ export function CoursesPage() { setSaveError(null) try { - await createCourse({ + const orderParsed = parseInt(displayOrder.trim(), 10) + await createSubCategory({ category_id: Number(categoryId), - title: title.trim(), - description: description.trim(), + name: title.trim(), + description: description.trim() || null, + ...(Number.isFinite(orderParsed) && orderParsed >= 0 ? { display_order: orderParsed } : {}), }) handleCloseModal() - await fetchCourses() + await fetchSubCategories() } catch (err: any) { console.error("Failed to create course:", err) setSaveError(err.response?.data?.message || "Failed to create sub-category") @@ -150,20 +155,20 @@ export function CoursesPage() { } } - const handleDeleteClick = (course: Course) => { - setCourseToDelete(course) + const handleDeleteClick = (sub: CategorySubCategoryListItem) => { + setSubCategoryToDelete(sub) setShowDeleteModal(true) } const handleConfirmDelete = async () => { - if (!courseToDelete) return + if (!subCategoryToDelete) return setDeleting(true) try { - await deleteCourse(courseToDelete.id) + await deleteCourseSubCategory(subCategoryToDelete.id) setShowDeleteModal(false) - setCourseToDelete(null) - await fetchCourses() + setSubCategoryToDelete(null) + await fetchSubCategories() } catch (err) { console.error("Failed to delete course:", err) } finally { @@ -171,11 +176,11 @@ export function CoursesPage() { } } - const handleToggleStatus = async (course: Course) => { - setTogglingId(course.id) + const handleToggleStatus = async (sub: CategorySubCategoryListItem) => { + setTogglingId(sub.id) try { - await updateCourseStatus(course.id, !course.is_active) - await fetchCourses() + await updateSubCategory(sub.id, { is_active: !sub.is_active }) + await fetchSubCategories() } catch (err) { console.error("Failed to update course status:", err) } finally { @@ -183,35 +188,29 @@ export function CoursesPage() { } } - const handleEditClick = (course: Course) => { - setCourseToEdit(course) - setEditTitle(course.title || "") - setEditDescription(course.description || "") - setEditThumbnail(course.thumbnail || "") - setEditThumbnailFile(null) + const handleEditClick = (sub: CategorySubCategoryListItem) => { + setSubCategoryToEdit(sub) + setEditTitle(sub.name || "") + setEditDescription(sub.description ?? "") + setEditDisplayOrder(sub.display_order ?? 0) setUpdateError(null) setShowEditModal(true) } const handleCloseEditModal = () => { setShowEditModal(false) - setCourseToEdit(null) + setSubCategoryToEdit(null) setEditTitle("") setEditDescription("") - setEditThumbnail("") - setEditThumbnailFile(null) + setEditDisplayOrder(0) setUpdateError(null) } const handleUpdate = async () => { - if (!courseToEdit) return + if (!subCategoryToEdit) return if (!editTitle.trim()) { - setUpdateError("Title is required") - return - } - if (!editDescription.trim()) { - setUpdateError("Description is required") + setUpdateError("Name is required") return } @@ -219,23 +218,15 @@ export function CoursesPage() { setUpdateError(null) try { - await updateCourse(courseToEdit.id, { - title: editTitle.trim(), - description: editDescription.trim(), - is_active: courseToEdit.is_active, + await updateSubCategory(subCategoryToEdit.id, { + name: editTitle.trim(), + description: editDescription.trim() || null, + 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() - await fetchCourses() + await fetchSubCategories() } catch (err: any) { console.error("Failed to update course:", err) setUpdateError(err.response?.data?.message || "Failed to update sub-category") @@ -244,32 +235,19 @@ export function CoursesPage() { } } - const handleCourseClick = (courseId: number) => { - navigate(`/content/category/${categoryId}/courses/${courseId}/sub-modules`) + const handleOpenSubCategory = (subCategoryId: number) => { + navigate(`/content/category/${categoryId}/sub-categories/${subCategoryId}/courses`) } - const handleViewRatings = async (courseId: number) => { - 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 filteredSubCategories = useMemo(() => { const q = searchQuery.trim().toLowerCase() - if (!q) return courses - return courses.filter((course) => { - const haystack = `${course.title} ${course.description ?? ""} ${course.id}`.toLowerCase() + if (!q) return subCategories + return subCategories.filter((sub) => { + const haystack = + `${sub.name} ${sub.description ?? ""} ${sub.category_name} ${sub.id} ${sub.display_order}`.toLowerCase() return haystack.includes(q) }) - }, [courses, searchQuery]) + }, [subCategories, searchQuery]) if (loading) { 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 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 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 pages: (number | string)[] = [] if (totalPages <= 7) { @@ -328,7 +322,7 @@ export function CoursesPage() { {category?.name} Sub-categories

- {courses.length} sub-categories available + {subCategories.length} sub-categories available

@@ -339,219 +333,246 @@ export function CoursesPage() { - {/* Course table or empty state */} - - -
- - Sub-category Management - -
- - setSearchQuery(e.target.value)} - placeholder="Search sub-categories..." - className="pl-9" - /> -
-
-
- - {courses.length === 0 ? ( -
- -

No sub-categories yet

-

- No sub-categories found in this category. -

- -
- ) : filteredCourses.length === 0 ? ( -
-

No matching sub-categories

-

- Try a different search term. -

-
- ) : ( -
- - - - Sub-category - Status - Actions - - - - {paginatedCourses.map((course) => ( - handleCourseClick(course.id)} - > - -
- {course.title} -
- {course.description && ( -
- {course.description} -
- )} -
- - - {course.is_active ? "Active" : "Inactive"} - - - -
- - - - -
-
-
- ))} -
-
+ {/* Sub-category table — layout aligned with Activity Log (UserLogPage) */} +
+

Sub-category Management

+
+ + setSearchQuery(e.target.value)} + placeholder="Search sub-categories..." + className="pl-9" + /> +
+
-
-
- Showing - - {startEntry}-{endEntry} - - of - {totalCount} - entries - Rows per page -
- - -
-
-
- +
+
+ ) : ( +
+ + + + ID + SUB-CATEGORY + CATEGORY + DESCRIPTION + ORDER + CATEGORY ID + CREATED + STATUS + ACTIONS + + + + {filteredSubCategories.length === 0 ? ( + + +
+ +
+

No matching sub-categories

+

Try a different search term.

+
+
+
+
+ ) : ( + paginatedSubCategories.map((sub) => ( + handleOpenSubCategory(sub.id)} > - - - {getPageNumbers().map((n, idx) => - typeof n === "string" ? ( - - ... - - ) : ( - - ), - )} - + + + + + + + )) + )} +
+
+ + {totalCount > 0 ? ( +
+
+ Showing + + {startEntry}–{endEntry} + + of + {totalCount} + entries + Rows per page +
+ +
+
+ + {getPageNumbers().map((n, idx) => + typeof n === "string" ? ( + + ... + + ) : ( + + ), + )} + +
- )} - - + ) : null} +
+ )} - {/* Add Course Modal */} + {/* Add Sub-category Modal */} {showModal && (
@@ -575,14 +596,14 @@ export function CoursesPage() {
setTitle(e.target.value)} /> @@ -590,14 +611,14 @@ export function CoursesPage() {