mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
change things
This commit is contained in:
+33
-71
@@ -1,19 +1,17 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle, Trash2, X } from "lucide-react";
|
||||||
import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle, Trash2 } from "lucide-react";
|
|
||||||
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { QuestionCard, makeQuestion } from "../../components/courses/QuestionEditor";
|
import { QuestionCard, makeQuestion } from "./QuestionEditor";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
|
|
||||||
// ── Dirty-check snapshot ──────────────────────────────────────────────────────
|
// ── Dirty-check snapshot ──────────────────────────────────────────────────────
|
||||||
@@ -114,7 +112,7 @@ const TYPE_LABEL = {
|
|||||||
|
|
||||||
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, navContainerRef, errors }) {
|
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, navContainerRef, errors }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col rounded-lg border bg-card overflow-hidden">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-3 py-3 border-b shrink-0">
|
<div className="px-3 py-3 border-b shrink-0">
|
||||||
@@ -127,7 +125,7 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable list */}
|
{/* Scrollable list */}
|
||||||
<div ref={navContainerRef} className="flex-1 overflow-y-auto py-2">
|
<div ref={navContainerRef} className="max-h-96 overflow-y-auto py-2">
|
||||||
{questions.length === 0 ? (
|
{questions.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground text-center py-8 px-3">
|
<p className="text-xs text-muted-foreground text-center py-8 px-3">
|
||||||
No questions yet.
|
No questions yet.
|
||||||
@@ -216,15 +214,13 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function CourseAssessment() {
|
export default function AssessmentEditor({ courseId, onSaved, onCancel }) {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { courseId } = useParams();
|
|
||||||
const {
|
const {
|
||||||
createAssessment, updateAssessment,
|
createAssessment, updateAssessment,
|
||||||
bulkSyncAssessmentQuestions,
|
bulkSyncAssessmentQuestions,
|
||||||
course, loading,
|
loading,
|
||||||
} = useCourses();
|
} = useCourses();
|
||||||
|
|
||||||
const [localAssessment, setLocalAssessment] = useState(null);
|
const [localAssessment, setLocalAssessment] = useState(null);
|
||||||
@@ -258,7 +254,6 @@ export default function CourseAssessment() {
|
|||||||
const questionRefs = useRef([]);
|
const questionRefs = useRef([]);
|
||||||
const navItemRefs = useRef([]);
|
const navItemRefs = useRef([]);
|
||||||
const navContainerRef = useRef(null);
|
const navContainerRef = useRef(null);
|
||||||
const headerRef = useRef(null);
|
|
||||||
|
|
||||||
// ── Fetch — silently treat 404 as "no assessment yet" (create mode) ─────────
|
// ── Fetch — silently treat 404 as "no assessment yet" (create mode) ─────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -313,20 +308,6 @@ export default function CourseAssessment() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [initializing]);
|
}, [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 ─────────────────────────────────
|
// ── Scroll to keep active nav item visible ─────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const item = navItemRefs.current[activeIndex];
|
const item = navItemRefs.current[activeIndex];
|
||||||
@@ -363,15 +344,11 @@ export default function CourseAssessment() {
|
|||||||
return () => observers.forEach((o) => o.disconnect());
|
return () => observers.forEach((o) => o.disconnect());
|
||||||
}, [questions.length]);
|
}, [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 scrollToQuestion = (index) => {
|
||||||
const el = questionRefs.current[index];
|
questionRefs.current[index]?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
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" });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Question actions ───────────────────────────────────────────────────────
|
// ── Question actions ───────────────────────────────────────────────────────
|
||||||
@@ -530,6 +507,7 @@ export default function CourseAssessment() {
|
|||||||
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions });
|
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions });
|
||||||
localStorage.removeItem(DRAFT_KEY);
|
localStorage.removeItem(DRAFT_KEY);
|
||||||
setDraftInfo(null);
|
setDraftInfo(null);
|
||||||
|
onSaved?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmSave = async () => {
|
const handleConfirmSave = async () => {
|
||||||
@@ -542,26 +520,16 @@ export default function CourseAssessment() {
|
|||||||
|
|
||||||
// ── Render ─────────────────────────────────────────────────────────────────
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
<div className="space-y-4">
|
||||||
<PageMeta title={course ? `${course.title} – Assessment - STARR` : undefined} />
|
|
||||||
|
|
||||||
{/* ── Sticky header ── */}
|
{/* ── Toolbar ── */}
|
||||||
<div
|
<div className="flex items-center gap-2 rounded-lg border bg-card px-4 py-3">
|
||||||
ref={headerRef}
|
|
||||||
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
|
||||||
style={{ top: "var(--navbar-h)" }}
|
|
||||||
>
|
|
||||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
|
||||||
<div className="flex items-center gap-2 py-3">
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses")}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
<p className="text-sm font-semibold flex items-center gap-1.5">
|
||||||
<ClipboardList className="h-5 w-5 text-muted-foreground" />
|
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||||
Course Assessment
|
{localAssessment ? "Modify Assessment" : "Create Assessment"}
|
||||||
</h1>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -571,36 +539,29 @@ export default function CourseAssessment() {
|
|||||||
<span className="size-2 rounded-full bg-amber-500" />
|
<span className="size-2 rounded-full bg-amber-500" />
|
||||||
Draft saved {new Date(draftInfo.savedAt).toLocaleTimeString()}
|
Draft saved {new Date(draftInfo.savedAt).toLocaleTimeString()}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button type="button" variant="outline" size="sm" onClick={handleClearDraft}>
|
||||||
type="button"
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
variant="outline"
|
|
||||||
onClick={handleClearDraft}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
Clear draft
|
Clear draft
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{onCancel && (
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
||||||
|
<X className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button onClick={handleSave} disabled={loading || confirmLoading || !isDirty}>
|
<Button onClick={handleSave} disabled={loading || confirmLoading || !isDirty}>
|
||||||
{(loading || confirmLoading) ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
|
{(loading || confirmLoading) ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
|
||||||
Save Assessment
|
Save Assessment
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Split layout ── */}
|
{/* ── Split layout ── */}
|
||||||
<div className="flex flex-1 lg:container lg:mx-auto lg:px-6 px-0 w-full items-start">
|
<div className="flex flex-col lg:flex-row gap-6 items-start">
|
||||||
|
|
||||||
{/* LEFT — Navigator (desktop only) */}
|
{/* LEFT — Navigator (desktop only) */}
|
||||||
<div
|
<div className="hidden lg:block w-64 shrink-0">
|
||||||
className="hidden lg:flex flex-col w-60 shrink-0 border-r bg-background"
|
|
||||||
style={{
|
|
||||||
position: "sticky",
|
|
||||||
top: `calc(var(--navbar-h) + var(--assessment-h, 0px))`,
|
|
||||||
height: `calc(100vh - var(--navbar-h) - var(--assessment-h, 0px))`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<QuestionNavigator
|
<QuestionNavigator
|
||||||
questions={questions}
|
questions={questions}
|
||||||
activeIndex={activeIndex}
|
activeIndex={activeIndex}
|
||||||
@@ -613,13 +574,13 @@ export default function CourseAssessment() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT — Main content */}
|
{/* RIGHT — Main content */}
|
||||||
<div className="flex-1 min-w-0 px-4 lg:px-8 py-6">
|
<div className="flex-1 min-w-0 w-full">
|
||||||
{initializing ? (
|
{initializing ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Spinner className="h-6 w-6" />
|
<Spinner className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-w-5xl space-y-6 pb-16">
|
<div className="space-y-6 pb-16">
|
||||||
|
|
||||||
{/* ── Settings ── */}
|
{/* ── Settings ── */}
|
||||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||||
@@ -740,6 +701,7 @@ export default function CourseAssessment() {
|
|||||||
<div
|
<div
|
||||||
key={q._tempId ?? q.question_id ?? i}
|
key={q._tempId ?? q.question_id ?? i}
|
||||||
ref={(el) => (questionRefs.current[i] = el)}
|
ref={(el) => (questionRefs.current[i] = el)}
|
||||||
|
style={{ scrollMarginTop: "calc(var(--navbar-h, 64px) + 180px)" }}
|
||||||
onClick={() => setActiveIndex(i)}
|
onClick={() => setActiveIndex(i)}
|
||||||
>
|
>
|
||||||
<QuestionCard
|
<QuestionCard
|
||||||
+22
-36
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft, ClipboardList, NotebookPen,
|
ClipboardList, NotebookPen,
|
||||||
CheckCircle2, Circle, Users, Activity,
|
CheckCircle2, Circle, Users, Activity,
|
||||||
ChevronDown, ChevronUp,
|
ChevronDown, ChevronUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -10,7 +9,6 @@ import { toast } from "sonner";
|
|||||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -327,7 +325,7 @@ function LoadingSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
// ─── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: "questions", label: "Questions", icon: ClipboardList },
|
{ key: "questions", label: "Questions", icon: ClipboardList },
|
||||||
@@ -335,10 +333,7 @@ const TABS = [
|
|||||||
{ key: "sessions", label: "Sessions", icon: Activity },
|
{ key: "sessions", label: "Sessions", icon: Activity },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ViewAssessment() {
|
export default function AssessmentOverview({ courseId, onModify }) {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { courseId } = useParams();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
fetchAssessmentCompletions, fetchAssessmentSessions,
|
fetchAssessmentCompletions, fetchAssessmentSessions,
|
||||||
completions, sessions,
|
completions, sessions,
|
||||||
@@ -346,10 +341,12 @@ export default function ViewAssessment() {
|
|||||||
} = useCourses();
|
} = useCourses();
|
||||||
|
|
||||||
const [localAssessment, setLocalAssessment] = useState(null);
|
const [localAssessment, setLocalAssessment] = useState(null);
|
||||||
|
const [initializing, setInitializing] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState("questions");
|
const [activeTab, setActiveTab] = useState("questions");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
setInitializing(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get(`/admin/courses/${courseId}/assessment`);
|
const { data } = await api.get(`/admin/courses/${courseId}/assessment`);
|
||||||
setLocalAssessment(data?.data?.data ?? null);
|
setLocalAssessment(data?.data?.data ?? null);
|
||||||
@@ -357,6 +354,8 @@ export default function ViewAssessment() {
|
|||||||
if (err?.response?.status !== 404) {
|
if (err?.response?.status !== 404) {
|
||||||
toast(err?.response?.data?.message ?? "Could not load assessment.");
|
toast(err?.response?.data?.message ?? "Could not load assessment.");
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
setInitializing(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [courseId]);
|
}, [courseId]);
|
||||||
@@ -372,39 +371,32 @@ export default function ViewAssessment() {
|
|||||||
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
|
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
<div className="space-y-5">
|
||||||
<PageMeta title="View Assessment - STARR" />
|
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Toolbar ── */}
|
||||||
<div
|
<div className="flex items-center gap-2 rounded-lg border bg-card px-4 py-3">
|
||||||
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
|
||||||
style={{ top: "var(--navbar-h)" }}
|
|
||||||
>
|
|
||||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
|
||||||
<div className="flex items-center gap-3 py-3">
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses")}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
<p className="text-sm font-semibold flex items-center gap-1.5">
|
||||||
<ClipboardList className="h-5 w-5 text-muted-foreground" />
|
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||||
View Assessment
|
Assessment
|
||||||
</h1>
|
</p>
|
||||||
{localAssessment && (
|
{localAssessment && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}>
|
{localAssessment && (
|
||||||
|
<Button variant="outline" size="sm" onClick={onModify}>
|
||||||
<NotebookPen className="h-4 w-4 mr-2" />
|
<NotebookPen className="h-4 w-4 mr-2" />
|
||||||
Modify Assessment
|
Modify Assessment
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* ── Sub-tabs ── */}
|
||||||
{localAssessment && (
|
{localAssessment && (
|
||||||
<div className="flex gap-1 pb-0 -mb-px">
|
<div className="flex gap-1 border-b">
|
||||||
{TABS.map(({ key, label, icon: Icon }) => (
|
{TABS.map(({ key, label, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
@@ -420,19 +412,15 @@ export default function ViewAssessment() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Content ── */}
|
{/* ── Content ── */}
|
||||||
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
{initializing || (loading && !localAssessment) ? (
|
||||||
<div className="max-w-3xl mx-auto space-y-5 pb-16">
|
|
||||||
{loading && !localAssessment ? (
|
|
||||||
<LoadingSkeleton />
|
<LoadingSkeleton />
|
||||||
) : !localAssessment ? (
|
) : !localAssessment ? (
|
||||||
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
|
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
|
||||||
<ClipboardList className="h-8 w-8 text-muted-foreground/40" />
|
<ClipboardList className="h-8 w-8 text-muted-foreground/40" />
|
||||||
<p className="text-sm text-muted-foreground">No assessment has been created for this course yet.</p>
|
<p className="text-sm text-muted-foreground">No assessment has been created for this course yet.</p>
|
||||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}>
|
<Button size="sm" variant="outline" onClick={onModify}>
|
||||||
<NotebookPen className="h-4 w-4 mr-2" />
|
<NotebookPen className="h-4 w-4 mr-2" />
|
||||||
Create Assessment
|
Create Assessment
|
||||||
</Button>
|
</Button>
|
||||||
@@ -478,7 +466,5 @@ export default function ViewAssessment() {
|
|||||||
<SessionsTab sessions={sessions} loading={loading} />
|
<SessionsTab sessions={sessions} loading={loading} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 (
|
||||||
|
<AssessmentEditor
|
||||||
|
courseId={courseId}
|
||||||
|
onSaved={backToOverview}
|
||||||
|
onCancel={backToOverview}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AssessmentOverview
|
||||||
|
key={overviewKey}
|
||||||
|
courseId={courseId}
|
||||||
|
onModify={() => setMode("edit")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -72,9 +72,6 @@ export default function CoursesTable() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rowActions = buildRowActions({
|
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`),
|
onView: (row) => navigate(`/admin/courses/${row.course_id}/view`),
|
||||||
onEdit: (row) => navigate(`/admin/courses/${row.course_id}/edit`),
|
onEdit: (row) => navigate(`/admin/courses/${row.course_id}/edit`),
|
||||||
onArchive: (row) => setArchiveTarget(row),
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { useAuth } from "@/contexts/AuthContext";
|
|||||||
import DataTable from "@/components/generic/Table/DataTable";
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
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 { buildDataColumns, columnPinning } from "../../config/courses/units/columns.config";
|
||||||
import { buildToolbarActions } from "../../config/courses/units/toolbar.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 }) {
|
export default function UnitsTable({ courseId, returnTo }) {
|
||||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||||
const [archiveIds, setArchiveIds] = useState(null);
|
const [archiveIds, setArchiveIds] = useState(null);
|
||||||
const [attachOpen, setAttachOpen] = useState(false);
|
|
||||||
|
|
||||||
const tableRefsRef = useRef({
|
const tableRefsRef = useRef({
|
||||||
getFilters: () => [],
|
getFilters: () => [],
|
||||||
@@ -65,8 +62,7 @@ export default function UnitsTable({ courseId, returnTo }) {
|
|||||||
onArchive: (row) => setArchiveTarget(row),
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
}), [courseId]);
|
}), [courseId]);
|
||||||
|
|
||||||
const toolbarActions = [
|
const toolbarActions = buildToolbarActions({
|
||||||
...buildToolbarActions({
|
|
||||||
fetchUnits: (params) => fetchUnits(courseId, params),
|
fetchUnits: (params) => fetchUnits(courseId, params),
|
||||||
pagination,
|
pagination,
|
||||||
exportConfig,
|
exportConfig,
|
||||||
@@ -76,22 +72,7 @@ export default function UnitsTable({ courseId, returnTo }) {
|
|||||||
getFilters: () => tableRefsRef.current.getFilters(),
|
getFilters: () => tableRefsRef.current.getFilters(),
|
||||||
getSort: () => tableRefsRef.current.getSort(),
|
getSort: () => tableRefsRef.current.getSort(),
|
||||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
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: <Link2 className="h-3.5 w-3.5" />,
|
|
||||||
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 selectionActions = buildSelectionActions({
|
const selectionActions = buildSelectionActions({
|
||||||
exportConfig,
|
exportConfig,
|
||||||
@@ -174,15 +155,6 @@ export default function UnitsTable({ courseId, returnTo }) {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleArchiveSuccess}
|
onSuccess={handleArchiveSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Attach existing library units ── */}
|
|
||||||
<AttachUnitsDialog
|
|
||||||
open={attachOpen}
|
|
||||||
onOpenChange={setAttachOpen}
|
|
||||||
attachedUnitIds={units.map((u) => u.unit_id)}
|
|
||||||
onAttach={handleAttachUnits}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 [
|
return [
|
||||||
{
|
{
|
||||||
key: "view",
|
key: "view",
|
||||||
@@ -23,38 +23,6 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
|
|||||||
onClick: (row) => onMoveDown(row),
|
onClick: (row) => onMoveDown(row),
|
||||||
disabled: (row) => courses.findIndex((c) => c.course_id === row.course_id) >= courses.length - 1,
|
disabled: (row) => courses.findIndex((c) => c.course_id === row.course_id) >= courses.length - 1,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "view_units",
|
|
||||||
label: "View Units",
|
|
||||||
icon: <ShelvingUnit className="h-3.5 w-3.5" />,
|
|
||||||
className: "text-sky-700 hover:text-sky-600",
|
|
||||||
onClick: (row) => onViewUnits(row),
|
|
||||||
separator: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "create_assessment",
|
|
||||||
label: "Create Assessment",
|
|
||||||
icon: <PlusCircle className="h-3.5 w-3.5" />,
|
|
||||||
className: "text-purple-700 hover:text-purple-600",
|
|
||||||
onClick: (row) => onAssessment(row),
|
|
||||||
hidden: (row) => !!row.assessment_id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "view_assessment",
|
|
||||||
label: "View Assessment",
|
|
||||||
icon: <ClipboardList className="h-3.5 w-3.5" />,
|
|
||||||
className: "text-purple-700 hover:text-purple-600",
|
|
||||||
onClick: (row) => onViewAssessment(row),
|
|
||||||
hidden: (row) => !row.assessment_id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "modify_assessment",
|
|
||||||
label: "Modify Assessment",
|
|
||||||
icon: <NotebookPen className="h-3.5 w-3.5" />,
|
|
||||||
className: "text-purple-700 hover:text-purple-600",
|
|
||||||
onClick: (row) => onAssessment(row),
|
|
||||||
hidden: (row) => !row.assessment_id,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "archive",
|
key: "archive",
|
||||||
label: "Archive Course",
|
label: "Archive Course",
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, Pencil, Clock, BookOpen, Layers,
|
ArrowLeft, Pencil, Clock, BookOpen, Layers,
|
||||||
BadgeCheck, Tag, Star, Lock, ListChecks, BarChart2,
|
BadgeCheck, Tag, Star, Lock, ListChecks, BarChart2,
|
||||||
Trophy, Users, Award,
|
Trophy, Users, Award, ClipboardList,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList";
|
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 CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -296,8 +298,12 @@ function CourseDetailsTab({ course, loading, instructors, achievementKeys, achie
|
|||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: "details", label: "Course Details", icon: BookOpen },
|
{ key: "details", label: "Course Details", icon: BookOpen },
|
||||||
{ key: "progress", label: "Reading Progress", icon: BarChart2 },
|
{ 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 ─────────────────────────────────────────────────────────────────────
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ViewCourse() {
|
export default function ViewCourse() {
|
||||||
@@ -406,7 +412,7 @@ export default function ViewCourse() {
|
|||||||
|
|
||||||
{/* ── Content ── */}
|
{/* ── Content ── */}
|
||||||
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
||||||
<div className="max-w-3xl mx-auto pb-16">
|
<div className={WIDE_TABS.has(activeTab) ? "pb-16" : "max-w-3xl mx-auto pb-16"}>
|
||||||
{activeTab === "details" && (
|
{activeTab === "details" && (
|
||||||
<CourseDetailsTab
|
<CourseDetailsTab
|
||||||
course={course}
|
course={course}
|
||||||
@@ -420,6 +426,12 @@ export default function ViewCourse() {
|
|||||||
{activeTab === "progress" && (
|
{activeTab === "progress" && (
|
||||||
<CourseReadingProgressList courseId={courseId} />
|
<CourseReadingProgressList courseId={courseId} />
|
||||||
)}
|
)}
|
||||||
|
{activeTab === "units" && (
|
||||||
|
<UnitsTable courseId={courseId} />
|
||||||
|
)}
|
||||||
|
{activeTab === "assessment" && (
|
||||||
|
<CourseAssessmentPanel courseId={courseId} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { useNavigate, useParams, useLocation } from "react-router-dom";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/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 { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import api from "@/utils/api.util";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -16,6 +17,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
import DraftRequirementsEditor from "@/modules/admin/components/courses/DraftRequirementsEditor";
|
import DraftRequirementsEditor from "@/modules/admin/components/courses/DraftRequirementsEditor";
|
||||||
|
import AttachUnitsDialog from "@/modules/admin/components/library/AttachUnitsDialog";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
title: z.string().min(1, "Title is required."),
|
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 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 [step, setStep] = useState(0);
|
||||||
const [requirements, setRequirements] = useState([]);
|
const [requirements, setRequirements] = useState([]);
|
||||||
|
const [attachOpen, setAttachOpen] = useState(false);
|
||||||
|
|
||||||
const { register, trigger, getValues, formState: { errors, isDirty } } = useForm({
|
const { register, trigger, getValues, formState: { errors, isDirty } } = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -89,7 +93,7 @@ export default function AddUnit() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => (step === 0 ? navigate(backTarget) : setStep(0))}
|
onClick={() => (mode === "create" && step > 0 ? setStep(0) : navigate(backTarget))}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -101,6 +105,23 @@ export default function AddUnit() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Select existing vs. create new */}
|
||||||
|
<div className="flex items-start justify-between gap-3 pb-3 border-b border-border">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Attach an existing library unit to reuse its content, or create a new one from scratch.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setAttachOpen(true)}>
|
||||||
|
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Select
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setMode("create")}>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1.5" /> Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "create" && (
|
||||||
|
<>
|
||||||
{/* Stepper */}
|
{/* Stepper */}
|
||||||
<div className="flex items-center gap-0">
|
<div className="flex items-center gap-0">
|
||||||
{STEPS.map((s, i) => {
|
{STEPS.map((s, i) => {
|
||||||
@@ -196,10 +217,24 @@ export default function AddUnit() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{unsavedChangesDialog}
|
{unsavedChangesDialog}
|
||||||
|
|
||||||
|
<AttachUnitsDialog
|
||||||
|
open={attachOpen}
|
||||||
|
onOpenChange={setAttachOpen}
|
||||||
|
attachedUnitIds={course?.units?.map((u) => u.unit_id) ?? []}
|
||||||
|
onAttach={async (unitIds) => {
|
||||||
|
await api.post(`/admin/courses/${courseId}/units/attach`, { unit_ids: unitIds });
|
||||||
|
bypassOnce();
|
||||||
|
navigate(backTarget);
|
||||||
|
}}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { nanoid } from "nanoid";
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, ChevronLeft, ChevronRight, Check,
|
ArrowLeft, ChevronLeft, ChevronRight, Check,
|
||||||
FileText, BookOpen, LayoutTemplate, ClipboardCheck, ListChecks,
|
FileText, BookOpen, LayoutTemplate, ClipboardCheck, ListChecks,
|
||||||
Plus, Trash2,
|
Plus, Trash2, Link2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from "@/components/ui/drawer";
|
} from "@/components/ui/drawer";
|
||||||
import { BlockList, DEFAULT_CONTENT } from "@/components/generic/BlockList";
|
import { BlockList, DEFAULT_CONTENT } from "@/components/generic/BlockList";
|
||||||
import { AddBlockMenu } from "@/components/generic/AddBlockMenu";
|
import { AddBlockMenu } from "@/components/generic/AddBlockMenu";
|
||||||
|
import AttachLessonsDialog from "@/modules/admin/components/library/AttachLessonsDialog";
|
||||||
import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview";
|
import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
|
|
||||||
@@ -167,17 +168,54 @@ function LessonObjectives({ control, register, lessonIndex }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StepLessons({ control, register, errors }) {
|
function StepLessons({ control, register, errors, existingLessons, onRemoveExisting, onOpenAttach }) {
|
||||||
const { fields, append, remove } = useFieldArray({ control, name: "lessons" });
|
const { fields, append, remove } = useFieldArray({ control, name: "lessons" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{fields.length === 0 && (
|
<div className="flex items-start justify-between gap-3 pb-3 border-b border-border">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Attach existing library lessons to reuse content, or create new ones from scratch.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={onOpenAttach}>
|
||||||
|
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Select
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => append({ title: "", description: "", objectives: [], blocks: [] })}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1.5" /> Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.length === 0 && existingLessons.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No lessons yet. A unit can be created without any, but add one now if you'd like to build its content in this wizard.
|
No lessons yet. A unit can be created without any, but add one now if you'd like to build its content in this wizard.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{existingLessons.map((l) => (
|
||||||
|
<div key={l.lesson_id} className="border border-border rounded-lg p-4 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-sm font-medium truncate">{l.title}</span>
|
||||||
|
<Badge variant="outline" className="text-[10px] shrink-0">existing</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive h-7 px-2 shrink-0"
|
||||||
|
onClick={() => onRemoveExisting(l.lesson_id)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{fields.map((f, i) => (
|
{fields.map((f, i) => (
|
||||||
<div key={f.id} className="border border-border rounded-lg p-4 space-y-3">
|
<div key={f.id} className="border border-border rounded-lg p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -207,15 +245,6 @@ function StepLessons({ control, register, errors }) {
|
|||||||
<LessonObjectives control={control} register={register} lessonIndex={i} />
|
<LessonObjectives control={control} register={register} lessonIndex={i} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => append({ title: "", description: "", objectives: [], blocks: [] })}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-1" /> Add Lesson
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -355,7 +384,7 @@ function SummaryRow({ label, value }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StepReview({ data, tierCategories, requirements }) {
|
function StepReview({ data, tierCategories, requirements, existingLessons }) {
|
||||||
const tierName = tierCategories.find((c) => c.slug === data.subscription)?.name;
|
const tierName = tierCategories.find((c) => c.slug === data.subscription)?.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -370,6 +399,20 @@ function StepReview({ data, tierCategories, requirements }) {
|
|||||||
<SummaryRow label="Subscription" value={tierName || "No tier gate (open)"} />
|
<SummaryRow label="Subscription" value={tierName || "No tier gate (open)"} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{existingLessons.length > 0 && (
|
||||||
|
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">Attached from library ({existingLessons.length})</span>
|
||||||
|
</div>
|
||||||
|
{existingLessons.map((l, i) => (
|
||||||
|
<div key={l.lesson_id} className="flex items-center justify-between text-sm border-t border-border pt-2 first:border-t-0 first:pt-0">
|
||||||
|
<span>{i + 1}. {l.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{(data.lessons ?? []).length === 0 ? (
|
{(data.lessons ?? []).length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No lessons will be created with this unit.</p>
|
<p className="text-sm text-muted-foreground">No lessons will be created with this unit.</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -409,7 +452,7 @@ function StepRequirements({ requirements, setRequirements }) {
|
|||||||
// ─── Main Page ──────────────────────────────────────────────────────────────
|
// ─── Main Page ──────────────────────────────────────────────────────────────
|
||||||
export default function AddLibraryUnit() {
|
export default function AddLibraryUnit() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { createUnitFull, loading } = useLibrary();
|
const { createUnitFull, attachLessonsToUnit, lessonsFlat, loading } = useLibrary();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const { syncUnitRequirements } = useCourses();
|
const { syncUnitRequirements } = useCourses();
|
||||||
@@ -417,6 +460,8 @@ export default function AddLibraryUnit() {
|
|||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [requirements, setRequirements] = useState([]);
|
const [requirements, setRequirements] = useState([]);
|
||||||
const [tierCategories, setTierCategories] = useState([]);
|
const [tierCategories, setTierCategories] = useState([]);
|
||||||
|
const [existingLessons, setExistingLessons] = useState([]);
|
||||||
|
const [attachLessonsOpen, setAttachLessonsOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get("/admin/tiers/categories")
|
api.get("/admin/tiers/categories")
|
||||||
@@ -473,6 +518,10 @@ export default function AddLibraryUnit() {
|
|||||||
const newUnitId = result?.data?.data?.unit_id;
|
const newUnitId = result?.data?.data?.unit_id;
|
||||||
if (!newUnitId) return;
|
if (!newUnitId) return;
|
||||||
|
|
||||||
|
if (existingLessons.length > 0) {
|
||||||
|
await attachLessonsToUnit(newUnitId, existingLessons.map((l) => l.lesson_id));
|
||||||
|
}
|
||||||
|
|
||||||
if (requirements.length > 0) {
|
if (requirements.length > 0) {
|
||||||
const clean = requirements.map(({ _key, ...r }) => r);
|
const clean = requirements.map(({ _key, ...r }) => r);
|
||||||
await syncUnitRequirements(null, newUnitId, clean);
|
await syncUnitRequirements(null, newUnitId, clean);
|
||||||
@@ -551,7 +600,16 @@ export default function AddLibraryUnit() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<StepLessons control={control} register={register} errors={errors} />
|
<StepLessons
|
||||||
|
control={control}
|
||||||
|
register={register}
|
||||||
|
errors={errors}
|
||||||
|
existingLessons={existingLessons}
|
||||||
|
onRemoveExisting={(lessonId) =>
|
||||||
|
setExistingLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId))
|
||||||
|
}
|
||||||
|
onOpenAttach={() => setAttachLessonsOpen(true)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<StepPageBuilder control={control} setValue={setValue} />
|
<StepPageBuilder control={control} setValue={setValue} />
|
||||||
@@ -560,7 +618,12 @@ export default function AddLibraryUnit() {
|
|||||||
<StepRequirements requirements={requirements} setRequirements={setRequirements} />
|
<StepRequirements requirements={requirements} setRequirements={setRequirements} />
|
||||||
)}
|
)}
|
||||||
{step === 4 && (
|
{step === 4 && (
|
||||||
<StepReview data={getValues()} tierCategories={tierCategories} requirements={requirements} />
|
<StepReview
|
||||||
|
data={getValues()}
|
||||||
|
tierCategories={tierCategories}
|
||||||
|
requirements={requirements}
|
||||||
|
existingLessons={existingLessons}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -590,6 +653,19 @@ export default function AddLibraryUnit() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AttachLessonsDialog
|
||||||
|
open={attachLessonsOpen}
|
||||||
|
onOpenChange={setAttachLessonsOpen}
|
||||||
|
attachedLessonIds={existingLessons.map((l) => l.lesson_id)}
|
||||||
|
onAttach={(lessonIds) => {
|
||||||
|
const picked = lessonIds
|
||||||
|
.map((id) => lessonsFlat.find((l) => l.lesson_id === id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((l) => ({ lesson_id: l.lesson_id, title: l.title }));
|
||||||
|
setExistingLessons((prev) => [...prev, ...picked]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{unsavedChangesDialog}
|
{unsavedChangesDialog}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,8 +55,6 @@ import ArchivedLessonsList from '../pages/courses/lessons/ArchivedLessonsList'
|
|||||||
// Lesson Page Builder
|
// Lesson Page Builder
|
||||||
import LessonPageBuilder from '../pages/courses/lessons/LessonPageBuilder'
|
import LessonPageBuilder from '../pages/courses/lessons/LessonPageBuilder'
|
||||||
import ViewLessonPage from '../pages/courses/lessons/ViewLessonPage'
|
import ViewLessonPage from '../pages/courses/lessons/ViewLessonPage'
|
||||||
import CourseAssessment from '../pages/courses/CourseAssessment'
|
|
||||||
import ViewAssessment from '../pages/courses/ViewAssessment'
|
|
||||||
import ModifyQuiz from '../pages/courses/units/ModifyQuiz'
|
import ModifyQuiz from '../pages/courses/units/ModifyQuiz'
|
||||||
import ViewUnitQuiz from '../pages/courses/units/ViewUnitQuiz'
|
import ViewUnitQuiz from '../pages/courses/units/ViewUnitQuiz'
|
||||||
|
|
||||||
@@ -194,8 +192,6 @@ export const AdminRoutes = {
|
|||||||
{ path: 'add', element: <AddCourse /> },
|
{ path: 'add', element: <AddCourse /> },
|
||||||
{ path: ':courseId/view', element: <ViewCourse /> },
|
{ path: ':courseId/view', element: <ViewCourse /> },
|
||||||
{ path: ':courseId/edit', element: <EditCourse /> },
|
{ path: ':courseId/edit', element: <EditCourse /> },
|
||||||
{ path: ":courseId/assessment", element: <CourseAssessment /> },
|
|
||||||
{ path: ":courseId/assessment/view", element: <ViewAssessment /> },
|
|
||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user