diff --git a/src/modules/admin/pages/courses/CourseAssessment.jsx b/src/modules/admin/components/courses/AssessmentEditor.jsx similarity index 87% rename from src/modules/admin/pages/courses/CourseAssessment.jsx rename to src/modules/admin/components/courses/AssessmentEditor.jsx index defc3d8..12825b0 100644 --- a/src/modules/admin/pages/courses/CourseAssessment.jsx +++ b/src/modules/admin/components/courses/AssessmentEditor.jsx @@ -1,19 +1,17 @@ import { useEffect, useRef, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle, Trash2 } from "lucide-react"; +import { Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle, Trash2, X } from "lucide-react"; import { toast } from "sonner"; import { useCourses } from "@/contexts/AdminCoursesContext"; import { useAuth } from "@/contexts/AuthContext"; import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; -import { PageMeta } from "@/contexts/MetadataContext"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; -import { QuestionCard, makeQuestion } from "../../components/courses/QuestionEditor"; +import { QuestionCard, makeQuestion } from "./QuestionEditor"; import api from "@/utils/api.util"; // ── Dirty-check snapshot ────────────────────────────────────────────────────── @@ -114,7 +112,7 @@ const TYPE_LABEL = { function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, navContainerRef, errors }) { return ( -
+
{/* Header */}
@@ -127,7 +125,7 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs
{/* Scrollable list */} -
+
{questions.length === 0 ? (

No questions yet. @@ -216,15 +214,13 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs ); } -// ── Main Page ───────────────────────────────────────────────────────────────── +// ── Main ────────────────────────────────────────────────────────────────────── -export default function CourseAssessment() { - const navigate = useNavigate(); - const { courseId } = useParams(); +export default function AssessmentEditor({ courseId, onSaved, onCancel }) { const { createAssessment, updateAssessment, bulkSyncAssessmentQuestions, - course, loading, + loading, } = useCourses(); const [localAssessment, setLocalAssessment] = useState(null); @@ -258,7 +254,6 @@ export default function CourseAssessment() { const questionRefs = useRef([]); const navItemRefs = useRef([]); const navContainerRef = useRef(null); - const headerRef = useRef(null); // ── Fetch — silently treat 404 as "no assessment yet" (create mode) ───────── useEffect(() => { @@ -313,20 +308,6 @@ export default function CourseAssessment() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [initializing]); - // ── Measure sticky header → --assessment-h ──────────────────────────────── - useEffect(() => { - if (!headerRef.current) return; - const update = () => { - document.documentElement.style.setProperty( - "--assessment-h", - `${headerRef.current.offsetHeight}px` - ); - }; - update(); - window.addEventListener("resize", update); - return () => window.removeEventListener("resize", update); - }, []); - // ── Scroll to keep active nav item visible ───────────────────────────────── useEffect(() => { const item = navItemRefs.current[activeIndex]; @@ -363,15 +344,11 @@ export default function CourseAssessment() { return () => observers.forEach((o) => o.disconnect()); }, [questions.length]); - // ── Scroll helper with sticky offset ────────────────────────────────────── + // ── Scroll helper — a generous scroll-margin-top on each question keeps it + // clear of the page's sticky navbar/tab-bar above without needing to + // measure their heights. const scrollToQuestion = (index) => { - const el = questionRefs.current[index]; - if (!el) return; - const navbarH = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--navbar-h") || "0", 10); - const assessmentH = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--assessment-h") || "0", 10); - const offset = navbarH + assessmentH + 16; - const top = el.getBoundingClientRect().top + window.scrollY - offset; - window.scrollTo({ top, behavior: "smooth" }); + questionRefs.current[index]?.scrollIntoView({ behavior: "smooth", block: "start" }); }; // ── Question actions ─────────────────────────────────────────────────────── @@ -530,6 +507,7 @@ export default function CourseAssessment() { initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions }); localStorage.removeItem(DRAFT_KEY); setDraftInfo(null); + onSaved?.(); }; const handleConfirmSave = async () => { @@ -542,65 +520,48 @@ export default function CourseAssessment() { // ── Render ───────────────────────────────────────────────────────────────── return ( -

- +
- {/* ── Sticky header ── */} -
-
-
- -
-

- - Course Assessment -

-

- {questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""} -

-
- {draftInfo && ( -
- - - Draft saved {new Date(draftInfo.savedAt).toLocaleTimeString()} - - -
- )} -
-
+ )} + {onCancel && ( + + )} +
{/* ── Split layout ── */} -
+
{/* LEFT — Navigator (desktop only) */} -
+
{/* RIGHT — Main content */} -
+
{initializing ? (
) : ( -
+
{/* ── Settings ── */}
@@ -740,6 +701,7 @@ export default function CourseAssessment() {
(questionRefs.current[i] = el)} + style={{ scrollMarginTop: "calc(var(--navbar-h, 64px) + 180px)" }} onClick={() => setActiveIndex(i)} > ); -} \ No newline at end of file +} diff --git a/src/modules/admin/pages/courses/ViewAssessment.jsx b/src/modules/admin/components/courses/AssessmentOverview.jsx similarity index 74% rename from src/modules/admin/pages/courses/ViewAssessment.jsx rename to src/modules/admin/components/courses/AssessmentOverview.jsx index dab35c4..831a053 100644 --- a/src/modules/admin/pages/courses/ViewAssessment.jsx +++ b/src/modules/admin/components/courses/AssessmentOverview.jsx @@ -1,7 +1,6 @@ import { useEffect, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; import { - ArrowLeft, ClipboardList, NotebookPen, + ClipboardList, NotebookPen, CheckCircle2, Circle, Users, Activity, ChevronDown, ChevronUp, } from "lucide-react"; @@ -10,7 +9,6 @@ import { toast } from "sonner"; import { useCourses } from "@/contexts/AdminCoursesContext"; import api from "@/utils/api.util"; import { useDateFormat } from "@/hooks/useDateFormat"; -import { PageMeta } from "@/contexts/MetadataContext"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; @@ -327,7 +325,7 @@ function LoadingSkeleton() { ); } -// ─── Page ───────────────────────────────────────────────────────────────────── +// ─── Main ────────────────────────────────────────────────────────────────────── const TABS = [ { key: "questions", label: "Questions", icon: ClipboardList }, @@ -335,10 +333,7 @@ const TABS = [ { key: "sessions", label: "Sessions", icon: Activity }, ]; -export default function ViewAssessment() { - const navigate = useNavigate(); - const { courseId } = useParams(); - +export default function AssessmentOverview({ courseId, onModify }) { const { fetchAssessmentCompletions, fetchAssessmentSessions, completions, sessions, @@ -346,10 +341,12 @@ export default function ViewAssessment() { } = useCourses(); const [localAssessment, setLocalAssessment] = useState(null); + const [initializing, setInitializing] = useState(true); const [activeTab, setActiveTab] = useState("questions"); useEffect(() => { (async () => { + setInitializing(true); try { const { data } = await api.get(`/admin/courses/${courseId}/assessment`); setLocalAssessment(data?.data?.data ?? null); @@ -357,6 +354,8 @@ export default function ViewAssessment() { if (err?.response?.status !== 404) { toast(err?.response?.data?.message ?? "Could not load assessment."); } + } finally { + setInitializing(false); } })(); }, [courseId]); @@ -372,113 +371,100 @@ export default function ViewAssessment() { const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0); return ( -
- +
- {/* ── Header ── */} -
-
-
- -
-

- - View Assessment -

- {localAssessment && ( -

- {questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""} -

- )} -
- -
- - {/* Tabs */} + {/* ── Toolbar ── */} +
+
+

+ + Assessment +

{localAssessment && ( -
- {TABS.map(({ key, label, icon: Icon }) => ( - - ))} -
+

+ {questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""} +

)}
+ {localAssessment && ( + + )}
+ {/* ── Sub-tabs ── */} + {localAssessment && ( +
+ {TABS.map(({ key, label, icon: Icon }) => ( + + ))} +
+ )} + {/* ── Content ── */} -
-
- {loading && !localAssessment ? ( - - ) : !localAssessment ? ( -
- -

No assessment has been created for this course yet.

- -
- ) : activeTab === "questions" ? ( - <> - -
- {localAssessment.title || "Course Assessment"} - - - {localAssessment.is_required ? "Required" : "Optional"} - - - {localAssessment.passing_score ?? 70}% - - {localAssessment.time_limit_minutes ? `${localAssessment.time_limit_minutes} mins` : "No limit"} - - {questions.length} - - {localAssessment.max_questions ? `${localAssessment.max_questions} (random)` : `All (${questions.length})`} - - {totalPoints} - {localAssessment.max_attempts ?? 3} - {localAssessment.cooldown_hours ?? 24}h -
-
- -
-

Questions

- {questions.length === 0 ? ( -
-

No questions added yet.

-
- ) : ( - questions.map((q, i) => ) - )} -
- - ) : activeTab === "completions" ? ( - - ) : ( - - )} + {initializing || (loading && !localAssessment) ? ( + + ) : !localAssessment ? ( +
+ +

No assessment has been created for this course yet.

+
-
+ ) : activeTab === "questions" ? ( + <> + +
+ {localAssessment.title || "Course Assessment"} + + + {localAssessment.is_required ? "Required" : "Optional"} + + + {localAssessment.passing_score ?? 70}% + + {localAssessment.time_limit_minutes ? `${localAssessment.time_limit_minutes} mins` : "No limit"} + + {questions.length} + + {localAssessment.max_questions ? `${localAssessment.max_questions} (random)` : `All (${questions.length})`} + + {totalPoints} + {localAssessment.max_attempts ?? 3} + {localAssessment.cooldown_hours ?? 24}h +
+
+ +
+

Questions

+ {questions.length === 0 ? ( +
+

No questions added yet.

+
+ ) : ( + questions.map((q, i) => ) + )} +
+ + ) : activeTab === "completions" ? ( + + ) : ( + + )}
); } diff --git a/src/modules/admin/components/courses/CourseAssessmentPanel.jsx b/src/modules/admin/components/courses/CourseAssessmentPanel.jsx new file mode 100644 index 0000000..ac39b78 --- /dev/null +++ b/src/modules/admin/components/courses/CourseAssessmentPanel.jsx @@ -0,0 +1,31 @@ +import { useState } from "react"; +import AssessmentOverview from "./AssessmentOverview"; +import AssessmentEditor from "./AssessmentEditor"; + +export default function CourseAssessmentPanel({ courseId }) { + const [mode, setMode] = useState("overview"); // "overview" | "edit" + const [overviewKey, setOverviewKey] = useState(0); // bump to force AssessmentOverview to refetch + + const backToOverview = () => { + setOverviewKey((k) => k + 1); + setMode("overview"); + }; + + if (mode === "edit") { + return ( + + ); + } + + return ( + setMode("edit")} + /> + ); +} diff --git a/src/modules/admin/components/courses/CourseTable.jsx b/src/modules/admin/components/courses/CourseTable.jsx index 2cdad68..41d9a20 100644 --- a/src/modules/admin/components/courses/CourseTable.jsx +++ b/src/modules/admin/components/courses/CourseTable.jsx @@ -72,9 +72,6 @@ export default function CoursesTable() { }; const rowActions = buildRowActions({ - onViewAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment/view`), - onAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment`), - onViewUnits: (row) => navigate(`/admin/courses/${row.course_id}/units`), onView: (row) => navigate(`/admin/courses/${row.course_id}/view`), onEdit: (row) => navigate(`/admin/courses/${row.course_id}/edit`), onArchive: (row) => setArchiveTarget(row), diff --git a/src/modules/admin/components/courses/UnitsTable.jsx b/src/modules/admin/components/courses/UnitsTable.jsx index be16201..53ecd1d 100644 --- a/src/modules/admin/components/courses/UnitsTable.jsx +++ b/src/modules/admin/components/courses/UnitsTable.jsx @@ -8,8 +8,6 @@ import { useAuth } from "@/contexts/AuthContext"; import DataTable from "@/components/generic/Table/DataTable"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog"; -import AttachUnitsDialog from "../library/AttachUnitsDialog"; -import { Link2 } from "lucide-react"; import { buildDataColumns, columnPinning } from "../../config/courses/units/columns.config"; import { buildToolbarActions } from "../../config/courses/units/toolbar.config"; @@ -22,7 +20,6 @@ import { formatGeneratedBy } from "@/utils/generatedBy.util"; export default function UnitsTable({ courseId, returnTo }) { const [archiveTarget, setArchiveTarget] = useState(null); const [archiveIds, setArchiveIds] = useState(null); - const [attachOpen, setAttachOpen] = useState(false); const tableRefsRef = useRef({ getFilters: () => [], @@ -65,33 +62,17 @@ export default function UnitsTable({ courseId, returnTo }) { onArchive: (row) => setArchiveTarget(row), }), [courseId]); - const toolbarActions = [ - ...buildToolbarActions({ - fetchUnits: (params) => fetchUnits(courseId, params), - pagination, - exportConfig, - navigate, - courseId, - returnTo, - getFilters: () => tableRefsRef.current.getFilters(), - getSort: () => tableRefsRef.current.getSort(), - getTableInstance: () => tableRefsRef.current.tableInstance, - }), - // Junction revamp — units live standalone in the library; attach without re-creating - { - key: "attach-existing", - type: "button", - label: "Attach Existing", - icon: , - variant: "outline", - onClick: () => setAttachOpen(true), - }, - ]; - - const handleAttachUnits = async (unitIds) => { - await api.post(`/admin/courses/${courseId}/units/attach`, { unit_ids: unitIds }); - fetchUnits(courseId, { page: 1, limit: pagination?.limit ?? 10 }); - }; + const toolbarActions = buildToolbarActions({ + fetchUnits: (params) => fetchUnits(courseId, params), + pagination, + exportConfig, + navigate, + courseId, + returnTo, + getFilters: () => tableRefsRef.current.getFilters(), + getSort: () => tableRefsRef.current.getSort(), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); const selectionActions = buildSelectionActions({ exportConfig, @@ -174,15 +155,6 @@ export default function UnitsTable({ courseId, returnTo }) { loading={loading} onSuccess={handleArchiveSuccess} /> - - {/* ── Attach existing library units ── */} - u.unit_id)} - onAttach={handleAttachUnits} - loading={loading} - /> ); } diff --git a/src/modules/admin/config/courses/rowActions.config.jsx b/src/modules/admin/config/courses/rowActions.config.jsx index 099ac76..832c812 100644 --- a/src/modules/admin/config/courses/rowActions.config.jsx +++ b/src/modules/admin/config/courses/rowActions.config.jsx @@ -1,6 +1,6 @@ -import { Eye, Archive, ShelvingUnit, NotebookPen, ClipboardList, PlusCircle, ArrowUp, ArrowDown } from "lucide-react"; +import { Eye, Archive, ArrowUp, ArrowDown } from "lucide-react"; -export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAssessment, onViewAssessment, onMoveUp, onMoveDown, courses = [] }) { +export function buildRowActions({ onView, onEdit, onArchive, onMoveUp, onMoveDown, courses = [] }) { return [ { key: "view", @@ -23,38 +23,6 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse onClick: (row) => onMoveDown(row), disabled: (row) => courses.findIndex((c) => c.course_id === row.course_id) >= courses.length - 1, }, - { - key: "view_units", - label: "View Units", - icon: , - className: "text-sky-700 hover:text-sky-600", - onClick: (row) => onViewUnits(row), - separator: true, - }, - { - key: "create_assessment", - label: "Create Assessment", - icon: , - className: "text-purple-700 hover:text-purple-600", - onClick: (row) => onAssessment(row), - hidden: (row) => !!row.assessment_id, - }, - { - key: "view_assessment", - label: "View Assessment", - icon: , - className: "text-purple-700 hover:text-purple-600", - onClick: (row) => onViewAssessment(row), - hidden: (row) => !row.assessment_id, - }, - { - key: "modify_assessment", - label: "Modify Assessment", - icon: , - className: "text-purple-700 hover:text-purple-600", - onClick: (row) => onAssessment(row), - hidden: (row) => !row.assessment_id, - }, { key: "archive", label: "Archive Course", diff --git a/src/modules/admin/pages/courses/ViewCourse.jsx b/src/modules/admin/pages/courses/ViewCourse.jsx index c6e2f1f..f543dcb 100644 --- a/src/modules/admin/pages/courses/ViewCourse.jsx +++ b/src/modules/admin/pages/courses/ViewCourse.jsx @@ -3,13 +3,15 @@ import { useNavigate, useParams } from "react-router-dom"; import { ArrowLeft, Pencil, Clock, BookOpen, Layers, BadgeCheck, Tag, Star, Lock, ListChecks, BarChart2, - Trophy, Users, Award, + Trophy, Users, Award, ClipboardList, } from "lucide-react"; import { useCourses } from "@/contexts/AdminCoursesContext"; import { useDateFormat } from "@/hooks/useDateFormat"; import { PageMeta } from "@/contexts/MetadataContext"; import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList"; +import UnitsTable from "../../components/courses/UnitsTable"; +import CourseAssessmentPanel from "../../components/courses/CourseAssessmentPanel"; import CourseBadge from "@/modules/admin/components/courses/CourseBadge"; import api from "@/utils/api.util"; import { Button } from "@/components/ui/button"; @@ -294,10 +296,14 @@ function CourseDetailsTab({ course, loading, instructors, achievementKeys, achie // ─── Tabs config ─────────────────────────────────────────────────────────────── const TABS = [ - { key: "details", label: "Course Details", icon: BookOpen }, - { key: "progress", label: "Reading Progress", icon: BarChart2 }, + { key: "details", label: "Course Details", icon: BookOpen }, + { key: "progress", label: "Reading Progress", icon: BarChart2 }, + { key: "units", label: "Units", icon: Layers }, + { key: "assessment", label: "Assessment", icon: ClipboardList }, ]; +const WIDE_TABS = new Set(["units", "assessment"]); + // ─── Page ───────────────────────────────────────────────────────────────────── export default function ViewCourse() { @@ -406,7 +412,7 @@ export default function ViewCourse() { {/* ── Content ── */}
-
+
{activeTab === "details" && ( )} + {activeTab === "units" && ( + + )} + {activeTab === "assessment" && ( + + )}
diff --git a/src/modules/admin/pages/courses/units/AddUnit.jsx b/src/modules/admin/pages/courses/units/AddUnit.jsx index 6a834ad..e807799 100644 --- a/src/modules/admin/pages/courses/units/AddUnit.jsx +++ b/src/modules/admin/pages/courses/units/AddUnit.jsx @@ -3,12 +3,13 @@ import { useNavigate, useParams, useLocation } from "react-router-dom"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; -import { ChevronLeft, ChevronRight, Check, FileText, ListChecks } from "lucide-react"; +import { ChevronLeft, ChevronRight, Check, FileText, ListChecks, Link2, Plus } from "lucide-react"; import { useCourses } from "@/contexts/AdminCoursesContext"; import { useAuth } from "@/contexts/AuthContext"; import { PageMeta } from "@/contexts/MetadataContext"; import { cn } from "@/lib/utils"; +import api from "@/utils/api.util"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -16,6 +17,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Spinner } from "@/components/ui/spinner"; import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; import DraftRequirementsEditor from "@/modules/admin/components/courses/DraftRequirementsEditor"; +import AttachUnitsDialog from "@/modules/admin/components/library/AttachUnitsDialog"; const schema = z.object({ title: z.string().min(1, "Title is required."), @@ -42,8 +44,10 @@ export default function AddUnit() { const backTarget = location.state?.returnTo ?? `/admin/courses/${courseId}/units`; + const [mode, setMode] = useState(null); // null | "create" — the Details/Requirements form only shows once "Create" is chosen const [step, setStep] = useState(0); const [requirements, setRequirements] = useState([]); + const [attachOpen, setAttachOpen] = useState(false); const { register, trigger, getValues, formState: { errors, isDirty } } = useForm({ resolver: zodResolver(schema), @@ -89,7 +93,7 @@ export default function AddUnit() { type="button" variant="ghost" size="icon" - onClick={() => (step === 0 ? navigate(backTarget) : setStep(0))} + onClick={() => (mode === "create" && step > 0 ? setStep(0) : navigate(backTarget))} > @@ -101,105 +105,136 @@ export default function AddUnit() {
- {/* Stepper */} -
- {STEPS.map((s, i) => { - const Icon = s.icon; - const isActive = step === i; - const isDone = step > i; + {/* Select existing vs. create new */} +
+

+ Attach an existing library unit to reuse its content, or create a new one from scratch. +

+
+ + +
+
- return ( -
-
-
- {isDone ? : } + {mode === "create" && ( + <> + {/* Stepper */} +
+ {STEPS.map((s, i) => { + const Icon = s.icon; + const isActive = step === i; + const isDone = step > i; + + return ( +
+
+
+ {isDone ? : } +
+ +
+ {i < STEPS.length - 1 && ( +
i ? "bg-emerald-600" : "bg-border" + )} /> + )} +
+ ); + })} +
+ + {/* Step content */} +
+ {step === 0 && ( +
+
+ + + +
+ +
+ +