import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { BookOpen, GraduationCap, Layers, PlayCircle, RefreshCw, Pencil, Search, Trash2 } from "lucide-react" import { useSearchParams } from "react-router-dom" import { toast } from "sonner" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Button } from "../../components/ui/button" import { Select } from "../../components/ui/select" import { Badge } from "../../components/ui/badge" import { deleteParentLinkedPractice, getLearningPrograms, getModuleLessons, getPracticesByParentCourse, getPracticesByParentLesson, getPracticesByParentModule, getProgramCourses, getTopLevelCourseModules, updateParentLinkedPractice, } from "../../api/courses.api" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "../../components/ui/dialog" import { Input } from "../../components/ui/input" import { Textarea } from "../../components/ui/textarea" import type { LearningProgramListItem, ParentContextPractice, ProgramCourseListItem, TopLevelCourseModuleItem, TopLevelModuleLessonItem, UpdateParentLinkedPracticeRequest, } from "../../types/course.types" import { SpinnerIcon } from "../../components/ui/spinner-icon" import { CreatePracticeWizard } from "./components/CreatePracticeWizard" import type { PracticeParentKind } from "../../types/course.types" import { cn } from "../../lib/utils" type ParentTab = "course" | "module" | "lesson" type FlowMode = "view" | "create" function toApiParentKind(tab: ParentTab): PracticeParentKind { if (tab === "course") return "COURSE" if (tab === "module") return "MODULE" return "LESSON" } function parseParentTab(s: string | null): ParentTab { if (s === "module" || s === "lesson" || s === "course") return s return "course" } function parseQuickTipParts(text: string): string[] { return text .split(",") .map((p) => p.trim()) .filter(Boolean) } function formatDate(iso: string): string { const d = new Date(iso) return Number.isNaN(d.getTime()) ? iso : d.toLocaleString() } const parentTabCopy: Record = { course: { label: "Course", hint: "Pick a program, then the course the practice is attached to.", icon: BookOpen }, module: { label: "Module", hint: "Pick program → course → module.", icon: Layers }, lesson: { label: "Lesson", hint: "Pick program → course → module → video lesson.", icon: PlayCircle }, } const LIST_LIMIT = 200 const sortBySortOrder = (items: T[]): T[] => [...items].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) function courseLabel(c: ProgramCourseListItem) { return c.name?.trim() || `Course #${c.id}` } function moduleLabel(m: TopLevelCourseModuleItem) { return m.name?.trim() || `Module #${m.id}` } function lessonLabel(l: TopLevelModuleLessonItem) { return l.title?.trim() || `Lesson #${l.id}` } function programLabel(p: LearningProgramListItem) { return p.name?.trim() || `Program #${p.id}` } function parseIntParam(s: string | null): number | null { if (!s?.trim() || !/^\d+$/.test(s.trim())) return null const n = Number(s) return Number.isFinite(n) && n > 0 ? n : null } /** When URL matches what is already on screen (after "Load"), skip network to fill dropdowns. */ function selectionMatchesUrl( type: ParentTab, leafIdParam: string, sp: URLSearchParams, pId: string, cId: string, mId: string, lId: string, ): boolean { const pro = sp.get("program") const cou = sp.get("course") const mod = sp.get("module") if (type === "course") { if (!pId || cId !== leafIdParam) return false return pro === pId } if (type === "module") { if (!pId || !cId || mId !== leafIdParam) return false return pro === pId && cou === cId } if (type === "lesson") { if (!pId || !cId || !mId || lId !== leafIdParam) return false return pro === pId && cou === cId && mod === mId } return false } export function PracticeDetailsPage() { const [searchParams, setSearchParams] = useSearchParams() const [parentTab, setParentTab] = useState(() => parseParentTab(searchParams.get("type"))) const [programs, setPrograms] = useState([]) const [courses, setCourses] = useState([]) const [modules, setModules] = useState([]) const [lessons, setLessons] = useState([]) const [programId, setProgramId] = useState("") const [courseId, setCourseId] = useState("") const [moduleId, setModuleId] = useState("") const [lessonId, setLessonId] = useState("") const [programsLoading, setProgramsLoading] = useState(true) const [coursesLoading, setCoursesLoading] = useState(false) const [modulesLoading, setModulesLoading] = useState(false) const [lessonsLoading, setLessonsLoading] = useState(false) const [locationRestoring, setLocationRestoring] = useState(false) const programsForRestoreRef = useRef(null) useEffect(() => { if (programs.length) programsForRestoreRef.current = programs }, [programs]) const [loading, setLoading] = useState(false) const [selectionError, setSelectionError] = useState(null) const [loadError, setLoadError] = useState(null) const [practice, setPractice] = useState(null) const [totalCount, setTotalCount] = useState(0) const [flowMode, setFlowMode] = useState("view") const [editOpen, setEditOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) const [savePracticeLoading, setSavePracticeLoading] = useState(false) const [deletePracticeLoading, setDeletePracticeLoading] = useState(false) const [editTitle, setEditTitle] = useState("") const [editStory, setEditStory] = useState("") const [editImage, setEditImage] = useState("") const [editQuestionSetId, setEditQuestionSetId] = useState("") const [editQuickTips, setEditQuickTips] = useState("") const [editPersonaId, setEditPersonaId] = useState("") const syncTabFromUrl = useRef(false) useEffect(() => { if (syncTabFromUrl.current) return setParentTab(parseParentTab(searchParams.get("type"))) }, [searchParams]) useEffect(() => { if (editOpen && practice) { setEditTitle(practice.title) setEditStory(practice.story_description) setEditImage(practice.story_image) setEditQuestionSetId(String(practice.question_set_id)) setEditQuickTips(practice.quick_tips ?? "") setEditPersonaId(practice.persona_id != null ? String(practice.persona_id) : "") } }, [editOpen, practice]) const loadPrograms = useCallback(async () => { setProgramsLoading(true) try { const res = await getLearningPrograms({ limit: LIST_LIMIT, offset: 0 }) const list = res.data?.data?.programs ?? [] setPrograms(sortBySortOrder(list)) } catch { setPrograms([]) } finally { setProgramsLoading(false) } }, []) useEffect(() => { loadPrograms() }, [loadPrograms]) const loadCourses = useCallback(async (pid: number) => { setCoursesLoading(true) try { const res = await getProgramCourses(pid, { limit: LIST_LIMIT, offset: 0 }) const list = res.data?.data?.courses ?? [] setCourses(sortBySortOrder(list)) } catch { setCourses([]) } finally { setCoursesLoading(false) } }, []) const loadModules = useCallback(async (cid: number) => { setModulesLoading(true) try { const res = await getTopLevelCourseModules(cid, { limit: LIST_LIMIT, offset: 0 }) const list = res.data?.data?.modules ?? [] setModules(sortBySortOrder(list)) } catch { setModules([]) } finally { setModulesLoading(false) } }, []) const loadLessons = useCallback(async (mid: number) => { setLessonsLoading(true) try { const res = await getModuleLessons(mid, { limit: LIST_LIMIT, offset: 0 }) const list = res.data?.data?.lessons ?? [] setLessons(sortBySortOrder(list)) } catch { setLessons([]) } finally { setLessonsLoading(false) } }, []) const onProgramChange = (value: string) => { setSelectionError(null) setProgramId(value) setCourseId("") setModuleId("") setLessonId("") setCourses([]) setModules([]) setLessons([]) if (value) { loadCourses(Number(value)) } } const onCourseChange = (value: string) => { setSelectionError(null) setCourseId(value) setModuleId("") setLessonId("") setModules([]) setLessons([]) if (value && (parentTab === "module" || parentTab === "lesson")) { loadModules(Number(value)) } } const onModuleChange = (value: string) => { setSelectionError(null) setModuleId(value) setLessonId("") setLessons([]) if (value && parentTab === "lesson") { loadLessons(Number(value)) } } const onParentTabChange = (next: ParentTab) => { setSelectionError(null) setParentTab(next) setModuleId("") setLessonId("") setModules([]) setLessons([]) if (next === "course") { // module/lesson not needed; keep program + course if already chosen } else if (next === "module" && programId && courseId) { loadModules(Number(courseId)) } else if (next === "lesson" && programId && courseId) { loadModules(Number(courseId)) if (moduleId) { loadLessons(Number(moduleId)) } } } const getLeafId = useCallback((): number | null => { if (parentTab === "course") { if (!courseId) return null const n = Number(courseId) return Number.isFinite(n) && n > 0 ? n : null } if (parentTab === "module") { if (!moduleId) return null const n = Number(moduleId) return Number.isFinite(n) && n > 0 ? n : null } if (!lessonId) return null const n = Number(lessonId) return Number.isFinite(n) && n > 0 ? n : null }, [parentTab, courseId, moduleId, lessonId]) const runFetch = useCallback(async (tab: ParentTab, rawId: string) => { const id = Number(String(rawId).trim()) if (!rawId.trim() || !Number.isFinite(id) || id < 1) { setLoadError("Invalid selection (missing or invalid id).") setPractice(null) setTotalCount(0) return } setLoading(true) setLoadError(null) setSelectionError(null) setPractice(null) try { const params = { limit: 20, offset: 0 } const res = tab === "course" ? await getPracticesByParentCourse(id, params) : tab === "module" ? await getPracticesByParentModule(id, params) : await getPracticesByParentLesson(id, params) const data = res.data?.data const list = data?.practices ?? [] setTotalCount(data?.total_count ?? list.length) setPractice(list[0] ?? null) } catch (e: unknown) { const err = e as { response?: { data?: { message?: string } } } setLoadError(err.response?.data?.message || "Failed to load practices for this parent.") setPractice(null) setTotalCount(0) } finally { setLoading(false) } }, []) const restoreFromUrl = useCallback( async (type: ParentTab, targetId: number, sp: URLSearchParams) => { setLocationRestoring(true) try { const pHint = parseIntParam(sp.get("program")) const cHint = parseIntParam(sp.get("course")) const mHint = parseIntParam(sp.get("module")) /* ---- Fast path: hints in URL = a few parallel calls ---- */ if (type === "course" && pHint != null) { const [progRes, cRes] = await Promise.all([ getLearningPrograms({ limit: LIST_LIMIT, offset: 0 }), getProgramCourses(pHint, { limit: LIST_LIMIT, offset: 0 }), ]) const programList = sortBySortOrder(progRes.data?.data?.programs ?? []) setPrograms(programList) const cl = sortBySortOrder(cRes.data?.data?.courses ?? []) const c = cl.find((x) => x.id === targetId) if (c) { setProgramId(String(pHint)) setCourses(cl) setCourseId(String(c.id)) return } } if (type === "module" && pHint != null && cHint != null) { const [progRes, cRes, mRes] = await Promise.all([ getLearningPrograms({ limit: LIST_LIMIT, offset: 0 }), getProgramCourses(pHint, { limit: LIST_LIMIT, offset: 0 }), getTopLevelCourseModules(cHint, { limit: LIST_LIMIT, offset: 0 }), ]) setPrograms(sortBySortOrder(progRes.data?.data?.programs ?? [])) setCourses(sortBySortOrder(cRes.data?.data?.courses ?? [])) setProgramId(String(pHint)) setCourseId(String(cHint)) const ml = sortBySortOrder(mRes.data?.data?.modules ?? []) const m = ml.find((x) => x.id === targetId) if (m) { setModules(ml) setModuleId(String(m.id)) return } } if (type === "lesson" && pHint != null && cHint != null && mHint != null) { const [progRes, cRes, mRes, lRes] = await Promise.all([ getLearningPrograms({ limit: LIST_LIMIT, offset: 0 }), getProgramCourses(pHint, { limit: LIST_LIMIT, offset: 0 }), getTopLevelCourseModules(cHint, { limit: LIST_LIMIT, offset: 0 }), getModuleLessons(mHint, { limit: LIST_LIMIT, offset: 0 }), ]) setPrograms(sortBySortOrder(progRes.data?.data?.programs ?? [])) setCourses(sortBySortOrder(cRes.data?.data?.courses ?? [])) setModules(sortBySortOrder(mRes.data?.data?.modules ?? [])) setProgramId(String(pHint)) setCourseId(String(cHint)) setModuleId(String(mHint)) const ll = sortBySortOrder(lRes.data?.data?.lessons ?? []) const lesson = ll.find((x) => x.id === targetId) if (lesson) { setLessons(ll) setLessonId(String(lesson.id)) return } } /* ---- Legacy / wrong hints: programs list, then search ---- */ let programList: LearningProgramListItem[] = programsForRestoreRef.current ?? [] if (programList.length === 0) { const progRes = await getLearningPrograms({ limit: LIST_LIMIT, offset: 0 }) programList = sortBySortOrder(progRes.data?.data?.programs ?? []) } setPrograms(programList) if (type === "course") { const courseResults = await Promise.all( programList.map((p) => getProgramCourses(p.id, { limit: LIST_LIMIT, offset: 0 })), ) for (let i = 0; i < programList.length; i++) { const cl = sortBySortOrder(courseResults[i].data?.data?.courses ?? []) const c = cl.find((x) => x.id === targetId) if (c) { setProgramId(String(programList[i].id)) setCourses(cl) setCourseId(String(c.id)) return } } return } if (type === "module") { for (const p of programList) { const cRes = await getProgramCourses(p.id, { limit: LIST_LIMIT, offset: 0 }) const cl = sortBySortOrder(cRes.data?.data?.courses ?? []) const modBatches = await Promise.all( cl.map((c) => getTopLevelCourseModules(c.id, { limit: LIST_LIMIT, offset: 0 })), ) for (let j = 0; j < cl.length; j++) { const ml = sortBySortOrder(modBatches[j].data?.data?.modules ?? []) const m = ml.find((x) => x.id === targetId) if (m) { setProgramId(String(p.id)) setCourses(cl) setCourseId(String(cl[j].id)) setModules(ml) setModuleId(String(m.id)) return } } } return } if (type === "lesson") { for (const p of programList) { const cRes = await getProgramCourses(p.id, { limit: LIST_LIMIT, offset: 0 }) const cl = sortBySortOrder(cRes.data?.data?.courses ?? []) for (const c of cl) { const mRes = await getTopLevelCourseModules(c.id, { limit: LIST_LIMIT, offset: 0 }) const ml = sortBySortOrder(mRes.data?.data?.modules ?? []) const lessonBatches = await Promise.all( ml.map((m) => getModuleLessons(m.id, { limit: LIST_LIMIT, offset: 0 })), ) for (let k = 0; k < ml.length; k++) { const ll = sortBySortOrder(lessonBatches[k].data?.data?.lessons ?? []) const lesson = ll.find((x) => x.id === targetId) if (lesson) { setProgramId(String(p.id)) setCourses(cl) setCourseId(String(c.id)) setModules(ml) setModuleId(String(ml[k].id)) setLessons(ll) setLessonId(String(lesson.id)) return } } } } } } finally { setLocationRestoring(false) } }, [], ) /** Only re-run when the query string actually changes (not when the user only edits a dropdown). */ const lastUrlKeyProcessedRef = useRef(null) const selectionUrlSyncedRef = useRef(null) useEffect(() => { const idParam = searchParams.get("id") if (!idParam?.trim() || !/^\d+$/.test(idParam.trim())) { lastUrlKeyProcessedRef.current = null selectionUrlSyncedRef.current = null return } const type = parseParentTab(searchParams.get("type")) const id = Number(idParam) if (!Number.isFinite(id) || id < 1) return const urlKey = searchParams.toString() if (urlKey === lastUrlKeyProcessedRef.current) { return } lastUrlKeyProcessedRef.current = urlKey if (selectionMatchesUrl(type, idParam, searchParams, programId, courseId, moduleId, lessonId)) { if (selectionUrlSyncedRef.current === urlKey) return selectionUrlSyncedRef.current = urlKey syncTabFromUrl.current = true setParentTab(type) queueMicrotask(() => { syncTabFromUrl.current = false }) return } selectionUrlSyncedRef.current = null syncTabFromUrl.current = true setParentTab(type) queueMicrotask(() => { syncTabFromUrl.current = false }) void restoreFromUrl(type, id, searchParams) }, [searchParams, restoreFromUrl, programId, courseId, moduleId, lessonId]) useEffect(() => { const id = searchParams.get("id") const type = parseParentTab(searchParams.get("type")) if (!id?.trim() || !/^\d+$/.test(id.trim())) { return } runFetch(type, id) }, [searchParams, runFetch]) const handleLoad = () => { const leaf = getLeafId() if (leaf == null) { setSelectionError("Choose all required items from the lists before loading.") return } setSelectionError(null) setLoadError(null) const next: Record = { type: parentTab, id: String(leaf), } if (programId) next.program = programId if (courseId) next.course = courseId if (moduleId) next.module = moduleId setSearchParams(next) } const savePracticeEdit = async () => { if (!practice) return const qid = Number(editQuestionSetId.trim()) if (!Number.isFinite(qid) || qid < 1) { toast.error("Enter a valid question set id") return } if (!editTitle.trim() || !editStory.trim() || !editImage.trim()) { toast.error("Title, story description, and story image are required") return } const payload: UpdateParentLinkedPracticeRequest = { title: editTitle.trim(), story_description: editStory.trim(), story_image: editImage.trim(), question_set_id: qid, quick_tips: editQuickTips.trim(), } const p = editPersonaId.trim() if (p) { const n = Number(p) if (!Number.isFinite(n) || n < 1) { toast.error("Persona id must be a positive number or empty") return } payload.persona_id = n } else { payload.persona_id = null } setSavePracticeLoading(true) try { const res = await updateParentLinkedPractice(practice.id, payload) const updated = res.data?.data if (updated) { setPractice(updated) } else { setPractice({ ...practice, ...payload, id: practice.id, parent_kind: practice.parent_kind, parent_id: practice.parent_id, created_at: practice.created_at, }) } toast.success("Practice updated") setEditOpen(false) } catch (e: unknown) { const err = e as { response?: { data?: { message?: string } } } toast.error(err.response?.data?.message || "Failed to update practice") } finally { setSavePracticeLoading(false) } } const confirmDeletePractice = async () => { if (!practice) return setDeletePracticeLoading(true) try { await deleteParentLinkedPractice(practice.id) toast.success("Practice deleted") setDeleteOpen(false) const id = searchParams.get("id") const t = parseParentTab(searchParams.get("type")) if (id?.trim() && /^\d+$/.test(id.trim())) { await runFetch(t, id) } else { setPractice(null) setTotalCount(0) } } catch (e: unknown) { const err = e as { response?: { data?: { message?: string } } } toast.error(err.response?.data?.message || "Failed to delete practice") } finally { setDeletePracticeLoading(false) } } const quickTipParts = useMemo( () => (practice?.quick_tips ? parseQuickTipParts(practice.quick_tips) : []), [practice?.quick_tips], ) const canLoad = parentTab === "course" ? Boolean(programId && courseId) : parentTab === "module" ? Boolean(programId && courseId && moduleId) : Boolean(programId && courseId && moduleId && lessonId) const createWizardParent = useMemo((): { kind: PracticeParentKind; id: number } | null => { const leaf = getLeafId() if (leaf == null) return null return { kind: toApiParentKind(parentTab), id: leaf } }, [parentTab, getLeafId]) return (

Practice Management

View the practice linked to a course, module, or lesson. Each parent has at most one practice.

Look up practice

Choose a program and narrow down to the {parentTabCopy[parentTab].label.toLowerCase()} you need, then load. The URL includes program / course / module so shared links and refreshes load quickly.

Practice is attached to

Program

Course

{parentTab !== "course" && (

Module

)} {parentTab === "lesson" && (

Lesson

)}
{selectionError &&

{selectionError}

} {locationRestoring && (

Resolving your link…

)}

{(() => { const meta = parentTabCopy[parentTab] const Icon = meta.icon return ( <> {meta.hint} ) })()}

{flowMode === "create" && ( { setFlowMode("view") if (canLoad) { handleLoad() } }} /> )} {flowMode === "view" && (
Practice {searchParams.get("id") && ( {totalCount === 0 ? "No results" : totalCount === 1 ? "1 result" : `${totalCount} results (showing first)`} )}
{loading && (
Loading practice…
)} {!loading && loadError && searchParams.get("id") && (

{loadError}

)} {!loading && !loadError && searchParams.get("id") && !practice && (

No practice for this parent

There is no practice attached to this {parentTabCopy[parentTab].label.toLowerCase()}{" "} yet, or the selection may be wrong.

)} {!loading && !loadError && practice && (
{practice.story_image?.trim() ? ( ) : (
No banner image
)}
{practice.parent_kind} parent #{practice.parent_id} · practice #{practice.id}
{flowMode === "view" && (
)}

{practice.title}

Story

{practice.story_description?.trim() || "—"}

{quickTipParts.length > 0 && (

Quick tips

    {quickTipParts.map((tip, i) => (
  • {tip}
  • ))}
)}
Question set{" "} #{practice.question_set_id}

Created {formatDate(practice.created_at)}

)} {!loading && !loadError && !searchParams.get("id") && (
Choose a program{parentTab !== "course" ? ", course" : ""} {parentTab === "module" || parentTab === "lesson" ? ", module" : ""} {parentTab === "lesson" ? ", lesson" : ""} above, then Load practice.
)}
)} Edit practice

Title

setEditTitle(e.target.value)} />

Story description