diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index fd31883..d8b0682 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -41,6 +41,7 @@ import type { GetSubCoursePrerequisitesResponse, AddSubCoursePrerequisiteRequest, GetLearningPathResponse, + GetSubCourseEntryAssessmentResponse, ReorderItem, GetRatingsResponse, GetRatingsParams, @@ -217,6 +218,11 @@ export const removeSubCoursePrerequisite = (subCourseId: number, prerequisiteId: export const getLearningPath = (courseId: number) => http.get(`/course-management/courses/${courseId}/learning-path`) +export const getSubCourseEntryAssessment = (subCourseId: number) => + http.get( + `/question-sets/sub-courses/${subCourseId}/entry-assessment`, + ) + const buildReorderPayload = (items: ReorderItem[]) => { const normalized = items.map((item, idx) => ({ id: Number(item.id), diff --git a/src/pages/content-management/CourseFlowBuilderPage.tsx b/src/pages/content-management/CourseFlowBuilderPage.tsx index 6680ecd..cb3e233 100644 --- a/src/pages/content-management/CourseFlowBuilderPage.tsx +++ b/src/pages/content-management/CourseFlowBuilderPage.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useState } from "react" import { - BookOpen, + BadgeCheck, ChevronDown, + ChevronRight, GripVertical, Loader2, RefreshCw, - Video, + Sparkles, } from "lucide-react" import { DndContext, @@ -32,9 +33,10 @@ import { Badge } from "../../components/ui/badge" import { getCourseCategories, getCoursesByCategory, - getSubCoursesByCourse, - getVideosBySubCourse, + getLearningPath, getQuestionSetsByOwner, + getSubCourseEntryAssessment, + reorderCategories, reorderCourses, reorderSubCourses, reorderVideos, @@ -43,17 +45,31 @@ import { import type { Course, CourseCategory, - Practice, + LearningPath, + LearningPathPractice, + LearningPathVideo, QuestionSet, ReorderItem, - SubCourse, - SubCourseVideo, } from "../../types/course.types" import { cn } from "../../lib/utils" import { toast } from "sonner" -type VideoNode = SubCourseVideo & { display_order?: number } -type PracticeNode = Practice & { display_order?: number } +type PracticeListItem = LearningPathPractice & { display_order: number } + +function mapPracticeSetsToPracticeItems(sets: QuestionSet[]): PracticeListItem[] { + return sortByDisplayOrder( + sets + .filter((set) => set.set_type === "PRACTICE") + .map((set, idx) => ({ + id: set.id, + title: set.title, + status: set.status, + question_count: 0, + display_order: + typeof (set as any).display_order === "number" ? (set as any).display_order : idx, + })), + ) +} function normalizeParentId(value: number | string | null | undefined): number | null { if (value === null || value === undefined || value === "") return null @@ -64,35 +80,70 @@ function normalizeParentId(value: number | string | null | undefined): number | function sortByDisplayOrder(items: T[]) { return [...items].sort((a, b) => { - const aOrder = typeof (a as any).display_order === "number" ? (a as any).display_order : 0 - const bOrder = typeof (b as any).display_order === "number" ? (b as any).display_order : 0 - if (aOrder === bOrder) return a.id - b.id - return aOrder - bOrder + const ao = typeof (a as any).display_order === "number" ? (a as any).display_order : 0 + const bo = typeof (b as any).display_order === "number" ? (b as any).display_order : 0 + if (ao === bo) return a.id - b.id + return ao - bo }) } function toReorderItems(items: T[]): ReorderItem[] { - return items.map((item, index) => ({ id: item.id, position: index })) + return items.map((item, index) => ({ id: Number(item.id), position: index })) } -function withDisplayOrder(items: T[]) { - return items.map((item, index) => ({ ...(item as any), display_order: index })) as T[] -} - -function SortableNode({ +function SortableChip({ id, label, active, onClick, className, - badge, }: { id: number label: string active?: boolean onClick?: () => void className?: string - badge?: React.ReactNode +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + }) + + return ( +
+ + +
+ ) +} + +function SortableRow({ + id, + children, +}: { + id: number + children: React.ReactNode }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, @@ -102,49 +153,47 @@ function SortableNode({ ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }} className={cn( - "rounded-xl border bg-white px-2.5 py-2 shadow-sm", - active ? "border-brand-300 bg-brand-50" : "border-grayScale-200", + "rounded-xl border border-grayScale-200 bg-white", isDragging && "opacity-60 ring-2 ring-brand-300", - className, )} > -
+
- - {badge} + Drag
+
{children}
) } export function CourseFlowBuilderPage() { const [categories, setCategories] = useState([]) - const [selectedParentCategoryId, setSelectedParentCategoryId] = useState(null) - const [activeSubCategoryId, setActiveSubCategoryId] = useState(null) + const [selectedCategoryId, setSelectedCategoryId] = useState(null) + const [coursesByCategory, setCoursesByCategory] = useState>({}) + const [selectedCourseId, setSelectedCourseId] = useState(null) + const [learningPath, setLearningPath] = useState(null) - const [subCategoriesByParent, setSubCategoriesByParent] = useState>({}) - const [coursesBySubCategory, setCoursesBySubCategory] = useState>({}) - const [videosByCourse, setVideosByCourse] = useState>({}) - const [practicesByCourse, setPracticesByCourse] = useState>({}) + const [expandedSubCourseIds, setExpandedSubCourseIds] = useState>(new Set()) + const [practicesBySubCourse, setPracticesBySubCourse] = useState>( + {}, + ) + const [entryAssessmentBySubCourse, setEntryAssessmentBySubCourse] = useState>( + {}, + ) - const [expandedCourseIds, setExpandedCourseIds] = useState>(new Set()) const [loading, setLoading] = useState(true) - const [loadingSubCategories, setLoadingSubCategories] = useState(false) const [loadingCourses, setLoadingCourses] = useState(false) - const [loadingCourseContent, setLoadingCourseContent] = useState>({}) + const [loadingPath, setLoadingPath] = useState(false) + const [loadingPracticesBySubCourse, setLoadingPracticesBySubCourse] = useState>( + {}, + ) const [savingKey, setSavingKey] = useState(null) const sensors = useSensors( @@ -152,29 +201,25 @@ export function CourseFlowBuilderPage() { useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ) - const parentCategories = useMemo( + const topLevelCategories = useMemo( () => sortByDisplayOrder(categories.filter((c) => normalizeParentId(c.parent_id as any) === null)), [categories], ) - const subCategories = useMemo(() => { - if (!selectedParentCategoryId) return [] - return subCategoriesByParent[selectedParentCategoryId] ?? [] - }, [selectedParentCategoryId, subCategoriesByParent]) - const activeCourses = useMemo(() => { - if (!activeSubCategoryId) return [] - return coursesBySubCategory[activeSubCategoryId] ?? [] - }, [activeSubCategoryId, coursesBySubCategory]) + if (!selectedCategoryId) return [] + return coursesByCategory[selectedCategoryId] ?? [] + }, [selectedCategoryId, coursesByCategory]) useEffect(() => { const load = async () => { setLoading(true) try { const res = await getCourseCategories() - setCategories(sortByDisplayOrder(res.data.data.categories ?? [])) + const all = sortByDisplayOrder(res.data.data.categories ?? []) + setCategories(all) } catch { - toast.error("Failed to load categories.") + toast.error("Failed to load course categories.") } finally { setLoading(false) } @@ -183,205 +228,279 @@ export function CourseFlowBuilderPage() { }, []) useEffect(() => { - if (selectedParentCategoryId) return - if (parentCategories.length > 0) setSelectedParentCategoryId(parentCategories[0].id) - }, [parentCategories, selectedParentCategoryId]) + if (selectedCategoryId) return + if (topLevelCategories.length > 0) setSelectedCategoryId(topLevelCategories[0].id) + }, [topLevelCategories, selectedCategoryId]) useEffect(() => { - if (!selectedParentCategoryId) { - setActiveSubCategoryId(null) + if (!selectedCategoryId) { + setSelectedCourseId(null) return } - if (subCategories.length === 0) { - setActiveSubCategoryId(null) - return - } - if (!subCategories.some((c) => c.id === activeSubCategoryId)) { - setActiveSubCategoryId(subCategories[0].id) - } - }, [selectedParentCategoryId, subCategories, activeSubCategoryId]) - - useEffect(() => { - if (!selectedParentCategoryId) return - if (subCategoriesByParent[selectedParentCategoryId]) return - const load = async () => { - setLoadingSubCategories(true) - try { - const res = await getCoursesByCategory(selectedParentCategoryId) - setSubCategoriesByParent((prev) => ({ - ...prev, - [selectedParentCategoryId]: sortByDisplayOrder(res.data.data.courses ?? []), - })) - } catch { - toast.error("Failed to load sub-categories.") - } finally { - setLoadingSubCategories(false) + if (coursesByCategory[selectedCategoryId]) { + const existing = coursesByCategory[selectedCategoryId] + if (existing.length > 0 && !existing.some((c) => c.id === selectedCourseId)) { + setSelectedCourseId(existing[0].id) + } else if (existing.length === 0) { + setSelectedCourseId(null) } + return } - load() - }, [selectedParentCategoryId, subCategoriesByParent]) - - useEffect(() => { - if (!activeSubCategoryId) return - if (coursesBySubCategory[activeSubCategoryId]) return const load = async () => { setLoadingCourses(true) try { - const res = await getSubCoursesByCourse(activeSubCategoryId) - setCoursesBySubCategory((prev) => ({ - ...prev, - [activeSubCategoryId]: sortByDisplayOrder(res.data.data.sub_courses ?? []), - })) + const res = await getCoursesByCategory(selectedCategoryId) + const items = sortByDisplayOrder(res.data.data.courses ?? []) + setCoursesByCategory((prev) => ({ ...prev, [selectedCategoryId]: items })) + setSelectedCourseId(items[0]?.id ?? null) } catch { - toast.error("Failed to load courses.") + toast.error("Failed to load course sub-categories.") } finally { setLoadingCourses(false) } } load() - }, [activeSubCategoryId, coursesBySubCategory]) + }, [selectedCategoryId, coursesByCategory, selectedCourseId]) - const ensureCourseContentLoaded = async (courseId: number) => { - if (videosByCourse[courseId] && practicesByCourse[courseId]) return - setLoadingCourseContent((prev) => ({ ...prev, [courseId]: true })) + useEffect(() => { + if (!selectedCourseId) { + setLearningPath(null) + return + } + const load = async () => { + setLoadingPath(true) + try { + const res = await getLearningPath(selectedCourseId) + const path = res.data.data + setLearningPath({ + ...path, + sub_courses: sortByDisplayOrder(path.sub_courses ?? []), + }) + + // Practices source of truth: question sets by SUB_COURSE owner. + const subCourses = path.sub_courses ?? [] + if (subCourses.length > 0) { + const ownerResults = await Promise.all( + subCourses.map(async (sc) => { + const setsRes = await getQuestionSetsByOwner("SUB_COURSE", sc.id) + return [sc.id, mapPracticeSetsToPracticeItems((setsRes.data.data ?? []) as QuestionSet[])] as const + }), + ) + const practiceMap: Record = {} + ownerResults.forEach(([subCourseId, practiceItems]) => { + practiceMap[subCourseId] = practiceItems + }) + setPracticesBySubCourse(practiceMap) + } else { + setPracticesBySubCourse({}) + } + } catch { + toast.error("Failed to load course sub-category learning path.") + setLearningPath(null) + } finally { + setLoadingPath(false) + } + } + load() + }, [selectedCourseId]) + + const loadSubCoursePracticeAndEntry = async (subCourseId: number) => { + if (practicesBySubCourse[subCourseId] && entryAssessmentBySubCourse[subCourseId] !== undefined) return + setLoadingPracticesBySubCourse((prev) => ({ ...prev, [subCourseId]: true })) try { - const [videosRes, practicesRes] = await Promise.all([ - getVideosBySubCourse(courseId), - getQuestionSetsByOwner("SUB_COURSE", courseId), + const [setsRes, entryRes] = await Promise.allSettled([ + getQuestionSetsByOwner("SUB_COURSE", subCourseId), + getSubCourseEntryAssessment(subCourseId), ]) - setVideosByCourse((prev) => ({ + + // No practice sets is a valid empty-state scenario; do not toast for 404/empty. + let ownerSets: QuestionSet[] = [] + if (setsRes.status === "fulfilled") { + ownerSets = (setsRes.value.data.data ?? []) as QuestionSet[] + } else { + const status = setsRes.reason?.response?.status + if (status !== 404) { + throw setsRes.reason + } + } + + setPracticesBySubCourse((prev) => ({ ...prev, - [courseId]: sortByDisplayOrder((videosRes.data.data.videos ?? []) as VideoNode[]), + [subCourseId]: mapPracticeSetsToPracticeItems(ownerSets), })) - const practiceSets = ((practicesRes.data.data ?? []) as QuestionSet[]).filter( - (set) => set.set_type === "PRACTICE", - ) - setPracticesByCourse((prev) => ({ + + // Entry assessment may legitimately be absent. + let entryAssessment: QuestionSet | null = null + if (entryRes.status === "fulfilled") { + entryAssessment = (entryRes.value.data.data ?? null) as QuestionSet | null + } else { + const status = entryRes.reason?.response?.status + if (status !== 404) { + throw entryRes.reason + } + } + + setEntryAssessmentBySubCourse((prev) => ({ ...prev, - [courseId]: sortByDisplayOrder( - practiceSets.map((set, index) => ({ - id: set.id, - sub_course_id: courseId, - title: set.title, - description: set.description, - banner_image: "", - persona: set.persona, - is_active: set.status === "PUBLISHED", - display_order: - typeof (set as any).display_order === "number" ? (set as any).display_order : index, - })) as PracticeNode[], - ), + [subCourseId]: entryAssessment, })) } catch { - toast.error("Failed to load course content.") + toast.error("Failed to load practice sets for course.") } finally { - setLoadingCourseContent((prev) => ({ ...prev, [courseId]: false })) + setLoadingPracticesBySubCourse((prev) => ({ ...prev, [subCourseId]: false })) } } - const handleSubCategoryDragEnd = async (event: DragEndEvent) => { + const onCategoryDragEnd = async (event: DragEndEvent) => { const { active, over } = event - if (!over || active.id === over.id || !selectedParentCategoryId) return - const items = subCategories + if (!over || active.id === over.id) return + const items = topLevelCategories + if (items.length <= 1) return const oldIndex = items.findIndex((i) => i.id === Number(active.id)) const newIndex = items.findIndex((i) => i.id === Number(over.id)) if (oldIndex < 0 || newIndex < 0) return - const reordered = withDisplayOrder(arrayMove(items, oldIndex, newIndex)) + const reordered = arrayMove(items, oldIndex, newIndex).map((item, idx) => ({ + ...item, + display_order: idx, + })) + const previous = categories + const ids = new Set(reordered.map((r) => r.id)) + setCategories((prev) => [...prev.filter((c) => !ids.has(c.id)), ...reordered]) + setSavingKey("categories") + try { + await reorderCategories(toReorderItems(reordered)) + } catch (err: any) { + setCategories(previous) + toast.error(err?.response?.data?.message || "Failed to reorder categories.") + } finally { + setSavingKey(null) + } + } + + const onCoursesDragEnd = async (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id || !selectedCategoryId) return + const items = coursesByCategory[selectedCategoryId] ?? [] + if (items.length <= 1) return + const oldIndex = items.findIndex((i) => i.id === Number(active.id)) + const newIndex = items.findIndex((i) => i.id === Number(over.id)) + if (oldIndex < 0 || newIndex < 0) return + + const reordered = arrayMove(items, oldIndex, newIndex).map((item, idx) => ({ + ...item, + display_order: idx, + })) const previous = items - setSubCategoriesByParent((prev) => ({ ...prev, [selectedParentCategoryId]: reordered })) - setSavingKey("sub-categories") + setCoursesByCategory((prev) => ({ ...prev, [selectedCategoryId]: reordered })) + setSavingKey("courses") try { await reorderCourses(toReorderItems(reordered)) } catch (err: any) { - setSubCategoriesByParent((prev) => ({ ...prev, [selectedParentCategoryId]: previous })) - const message = - err?.response?.data?.error || - err?.response?.data?.message || - "Failed to reorder sub-categories." - toast.error(message) + setCoursesByCategory((prev) => ({ ...prev, [selectedCategoryId]: previous })) + toast.error(err?.response?.data?.message || "Failed to reorder courses.") } finally { setSavingKey(null) } } - const handleCoursesDragEnd = async (event: DragEndEvent) => { + const onSubCoursesDragEnd = async (event: DragEndEvent) => { const { active, over } = event - if (!over || active.id === over.id || !activeSubCategoryId) return - const items = coursesBySubCategory[activeSubCategoryId] ?? [] + if (!over || active.id === over.id || !learningPath) return + const items = learningPath.sub_courses ?? [] + if (items.length <= 1) return const oldIndex = items.findIndex((i) => i.id === Number(active.id)) const newIndex = items.findIndex((i) => i.id === Number(over.id)) if (oldIndex < 0 || newIndex < 0) return - const reordered = withDisplayOrder(arrayMove(items, oldIndex, newIndex)) + const reordered = arrayMove(items, oldIndex, newIndex).map((item, idx) => ({ + ...item, + display_order: idx, + })) const previous = items - setCoursesBySubCategory((prev) => ({ ...prev, [activeSubCategoryId]: reordered })) - setSavingKey("courses") + setLearningPath((prev) => (prev ? { ...prev, sub_courses: reordered } : prev)) + setSavingKey("sub-courses") try { await reorderSubCourses(toReorderItems(reordered)) - } catch { - setCoursesBySubCategory((prev) => ({ ...prev, [activeSubCategoryId]: previous })) - toast.error("Failed to reorder courses.") + } catch (err: any) { + setLearningPath((prev) => (prev ? { ...prev, sub_courses: previous } : prev)) + toast.error(err?.response?.data?.message || "Failed to reorder sub-courses.") } finally { setSavingKey(null) } } - const handleVideosDragEnd = async (courseId: number, event: DragEndEvent) => { + const onVideosDragEnd = async (subCourseId: number, event: DragEndEvent) => { const { active, over } = event - if (!over || active.id === over.id) return - const items = videosByCourse[courseId] ?? [] - const oldIndex = items.findIndex((i) => i.id === Number(active.id)) - const newIndex = items.findIndex((i) => i.id === Number(over.id)) + if (!over || active.id === over.id || !learningPath) return + const subCourses = learningPath.sub_courses ?? [] + const target = subCourses.find((s) => s.id === subCourseId) + if (!target || target.videos.length <= 1) return + const oldIndex = target.videos.findIndex((i) => i.id === Number(active.id)) + const newIndex = target.videos.findIndex((i) => i.id === Number(over.id)) if (oldIndex < 0 || newIndex < 0) return - const reordered = withDisplayOrder(arrayMove(items, oldIndex, newIndex)) - const previous = items - setVideosByCourse((prev) => ({ ...prev, [courseId]: reordered })) - setSavingKey(`videos-${courseId}`) + const reordered = arrayMove(target.videos, oldIndex, newIndex).map((item, idx) => ({ + ...item, + display_order: idx, + })) as LearningPathVideo[] + const previous = target.videos + setLearningPath((prev) => + prev + ? { + ...prev, + sub_courses: prev.sub_courses.map((sc) => + sc.id === subCourseId ? { ...sc, videos: reordered } : sc, + ), + } + : prev, + ) + setSavingKey(`videos-${subCourseId}`) try { await reorderVideos(toReorderItems(reordered)) - } catch { - setVideosByCourse((prev) => ({ ...prev, [courseId]: previous })) - toast.error("Failed to reorder videos.") + } catch (err: any) { + setLearningPath((prev) => + prev + ? { + ...prev, + sub_courses: prev.sub_courses.map((sc) => + sc.id === subCourseId ? { ...sc, videos: previous } : sc, + ), + } + : prev, + ) + toast.error(err?.response?.data?.message || "Failed to reorder videos.") } finally { setSavingKey(null) } } - const handlePracticesDragEnd = async (courseId: number, event: DragEndEvent) => { + const onPracticesDragEnd = async (subCourseId: number, event: DragEndEvent) => { const { active, over } = event if (!over || active.id === over.id) return - const items = practicesByCourse[courseId] ?? [] + const items = practicesBySubCourse[subCourseId] ?? [] + if (items.length <= 1) return const oldIndex = items.findIndex((i) => i.id === Number(active.id)) const newIndex = items.findIndex((i) => i.id === Number(over.id)) if (oldIndex < 0 || newIndex < 0) return - const reordered = withDisplayOrder(arrayMove(items, oldIndex, newIndex)) + const reordered = arrayMove(items, oldIndex, newIndex).map((item, idx) => ({ + ...item, + display_order: idx, + })) const previous = items - setPracticesByCourse((prev) => ({ ...prev, [courseId]: reordered })) - setSavingKey(`practices-${courseId}`) + setPracticesBySubCourse((prev) => ({ ...prev, [subCourseId]: reordered })) + setSavingKey(`practices-${subCourseId}`) try { await reorderPractices(toReorderItems(reordered)) - } catch { - setPracticesByCourse((prev) => ({ ...prev, [courseId]: previous })) - toast.error("Failed to reorder practices.") + } catch (err: any) { + setPracticesBySubCourse((prev) => ({ ...prev, [subCourseId]: previous })) + toast.error(err?.response?.data?.message || "Failed to reorder practices.") } finally { setSavingKey(null) } } - const toggleCourse = async (courseId: number) => { - const expanded = expandedCourseIds.has(courseId) - if (!expanded) await ensureCourseContentLoaded(courseId) - setExpandedCourseIds((prev) => { - const next = new Set(prev) - if (next.has(courseId)) next.delete(courseId) - else next.add(courseId) - return next - }) - } - if (loading) { return (
@@ -391,15 +510,15 @@ export function CourseFlowBuilderPage() { ) } - const selectedParentName = - parentCategories.find((c) => c.id === selectedParentCategoryId)?.name ?? "Course Category" return (
-
+
-

Learning Tree Builder

+

+ Learning Tree + Sequential Access +

- Arrange as: Course category → Course sub-category → Course (level) → Course videos/practices + Course Category → Course Sub-category → Course (level/sub-level) → Videos/Practices

-
- } - /> - -
- - {expanded && ( -
- {loadingCourseContent[course.id] ? ( -
- - Loading videos and practices... -
+ {subCourse.sub_level && ( + + {subCourse.sub_level} + + )} + {entryAssessmentBySubCourse[subCourse.id] && ( + + + Entry assessment + + )} +

+
+
+ + {subCourse.videos.length} videos / {practices.length || subCourse.practice_count} practices + + {expanded ? ( + ) : ( - <> -
- {pairCount === 0 ? ( -

- No videos/practices -

- ) : ( - Array.from({ length: pairCount }).map((_, idx) => { - const v = videos[idx] - const p = practices[idx] - return ( -
-
- - -
-
-
- - - {p ? p.title : "—"} - -
-
- ) - }) - )} -
- -
-
-

- Reorder videos -

- handleVideosDragEnd(course.id, event)} - > - item.id)} - strategy={verticalListSortingStrategy} - > -
- {videos.map((video) => ( - - ))} -
-
-
-
-
-

- Reorder practices -

- handlePracticesDragEnd(course.id, event)} - > - item.id)} - strategy={verticalListSortingStrategy} - > -
- {practices.map((practice) => ( - - ))} -
-
-
-
-
- + )}
+ + + {expanded && ( +
+
+

+ Videos (sequential) +

+ onVideosDragEnd(subCourse.id, event)} + > + item.id)} + strategy={verticalListSortingStrategy} + > +
+ {subCourse.videos.length === 0 ? ( +

+ No videos +

+ ) : ( + subCourse.videos.map((video) => ( + + )) + )} +
+
+
+
+ +
+

+ Practices (set_type=PRACTICE) +

+ {loadingPracticesBySubCourse[subCourse.id] ? ( +
+ + Loading sets... +
+ ) : ( + onPracticesDragEnd(subCourse.id, event)} + > + item.id)} + strategy={verticalListSortingStrategy} + > +
+ {practices.length === 0 ? ( +

+ No practices +

+ ) : ( + practices.map((practice) => ( + + )) + )} +
+
+
+ )} +
+
)} -
+ ) })}
)} -
+ + +
+ + + +

+ + Integration notes +

+

+ Reorder payload is strict: {"{ items: [{ id, position }] }"} with + 0-based full sibling lists. +

+

+ Practices load from /question-sets/by-owner filtered by + set_type=PRACTICE; entry assessment loads from dedicated course endpoint. +

diff --git a/src/types/course.types.ts b/src/types/course.types.ts index e7e6ce6..9b08d4a 100644 --- a/src/types/course.types.ts +++ b/src/types/course.types.ts @@ -470,12 +470,29 @@ export interface LearningPathSubCourse { thumbnail: string display_order: number level: string + sub_level?: string prerequisite_count: number video_count: number practice_count: number prerequisites: { sub_course_id: number; title: string; level: string }[] - videos: unknown[] - practices: unknown[] + videos: LearningPathVideo[] + practices: LearningPathPractice[] +} + +export interface LearningPathVideo { + id: number + title: string + display_order: number + duration: number + video_url: string +} + +export interface LearningPathPractice { + id: number + title: string + status: string + question_count: number + display_order?: number } export interface LearningPath { @@ -497,6 +514,14 @@ export interface GetLearningPathResponse { metadata: unknown } +export interface GetSubCourseEntryAssessmentResponse { + message: string + data: QuestionSet | null + success: boolean + status_code: number + metadata: unknown +} + export interface ReorderItem { id: number position: number