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 { 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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col rounded-lg border bg-card overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-3 py-3 border-b shrink-0">
|
||||
@@ -127,7 +125,7 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-8 px-3">
|
||||
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,26 +520,16 @@ export default function CourseAssessment() {
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
<PageMeta title={course ? `${course.title} – Assessment - STARR` : undefined} />
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* ── Sticky header ── */}
|
||||
<div
|
||||
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>
|
||||
{/* ── Toolbar ── */}
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-card px-4 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5 text-muted-foreground" />
|
||||
Course Assessment
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
{localAssessment ? "Modify Assessment" : "Create Assessment"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
@@ -571,36 +539,29 @@ export default function CourseAssessment() {
|
||||
<span className="size-2 rounded-full bg-amber-500" />
|
||||
Draft saved {new Date(draftInfo.savedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClearDraft}
|
||||
>
|
||||
<Trash2 />
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleClearDraft}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Clear draft
|
||||
</Button>
|
||||
</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}>
|
||||
{(loading || confirmLoading) ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
Save Assessment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 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) */}
|
||||
<div
|
||||
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))`,
|
||||
}}
|
||||
>
|
||||
<div className="hidden lg:block w-64 shrink-0">
|
||||
<QuestionNavigator
|
||||
questions={questions}
|
||||
activeIndex={activeIndex}
|
||||
@@ -613,13 +574,13 @@ export default function CourseAssessment() {
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-5xl space-y-6 pb-16">
|
||||
<div className="space-y-6 pb-16">
|
||||
|
||||
{/* ── Settings ── */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
@@ -740,6 +701,7 @@ export default function CourseAssessment() {
|
||||
<div
|
||||
key={q._tempId ?? q.question_id ?? i}
|
||||
ref={(el) => (questionRefs.current[i] = el)}
|
||||
style={{ scrollMarginTop: "calc(var(--navbar-h, 64px) + 180px)" }}
|
||||
onClick={() => setActiveIndex(i)}
|
||||
>
|
||||
<QuestionCard
|
||||
+22
-36
@@ -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,39 +371,32 @@ export default function ViewAssessment() {
|
||||
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
<PageMeta title="View Assessment - STARR" />
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div
|
||||
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>
|
||||
{/* ── Toolbar ── */}
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-card px-4 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5 text-muted-foreground" />
|
||||
View Assessment
|
||||
</h1>
|
||||
<p className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
Assessment
|
||||
</p>
|
||||
{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" : ""}
|
||||
</p>
|
||||
)}
|
||||
</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" />
|
||||
Modify Assessment
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
{/* ── Sub-tabs ── */}
|
||||
{localAssessment && (
|
||||
<div className="flex gap-1 pb-0 -mb-px">
|
||||
<div className="flex gap-1 border-b">
|
||||
{TABS.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
@@ -420,19 +412,15 @@ export default function ViewAssessment() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
||||
<div className="max-w-3xl mx-auto space-y-5 pb-16">
|
||||
{loading && !localAssessment ? (
|
||||
{initializing || (loading && !localAssessment) ? (
|
||||
<LoadingSkeleton />
|
||||
) : !localAssessment ? (
|
||||
<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" />
|
||||
<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" />
|
||||
Create Assessment
|
||||
</Button>
|
||||
@@ -478,7 +466,5 @@ export default function ViewAssessment() {
|
||||
<SessionsTab sessions={sessions} loading={loading} />
|
||||
)}
|
||||
</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({
|
||||
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),
|
||||
|
||||
@@ -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,8 +62,7 @@ export default function UnitsTable({ courseId, returnTo }) {
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
}), [courseId]);
|
||||
|
||||
const toolbarActions = [
|
||||
...buildToolbarActions({
|
||||
const toolbarActions = buildToolbarActions({
|
||||
fetchUnits: (params) => fetchUnits(courseId, params),
|
||||
pagination,
|
||||
exportConfig,
|
||||
@@ -76,22 +72,7 @@ export default function UnitsTable({ 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: <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({
|
||||
exportConfig,
|
||||
@@ -174,15 +155,6 @@ export default function UnitsTable({ courseId, returnTo }) {
|
||||
loading={loading}
|
||||
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 [
|
||||
{
|
||||
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: <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",
|
||||
label: "Archive Course",
|
||||
|
||||
@@ -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";
|
||||
@@ -296,8 +298,12 @@ function CourseDetailsTab({ course, loading, instructors, achievementKeys, achie
|
||||
const TABS = [
|
||||
{ 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 ── */}
|
||||
<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" && (
|
||||
<CourseDetailsTab
|
||||
course={course}
|
||||
@@ -420,6 +426,12 @@ export default function ViewCourse() {
|
||||
{activeTab === "progress" && (
|
||||
<CourseReadingProgressList courseId={courseId} />
|
||||
)}
|
||||
{activeTab === "units" && (
|
||||
<UnitsTable courseId={courseId} />
|
||||
)}
|
||||
{activeTab === "assessment" && (
|
||||
<CourseAssessmentPanel courseId={courseId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -101,6 +105,23 @@ export default function AddUnit() {
|
||||
</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 */}
|
||||
<div className="flex items-center gap-0">
|
||||
{STEPS.map((s, i) => {
|
||||
@@ -196,10 +217,24 @@ export default function AddUnit() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { nanoid } from "nanoid";
|
||||
import {
|
||||
ArrowLeft, ChevronLeft, ChevronRight, Check,
|
||||
FileText, BookOpen, LayoutTemplate, ClipboardCheck, ListChecks,
|
||||
Plus, Trash2,
|
||||
Plus, Trash2, Link2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "@/components/ui/drawer";
|
||||
import { BlockList, DEFAULT_CONTENT } from "@/components/generic/BlockList";
|
||||
import { AddBlockMenu } from "@/components/generic/AddBlockMenu";
|
||||
import AttachLessonsDialog from "@/modules/admin/components/library/AttachLessonsDialog";
|
||||
import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview";
|
||||
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" });
|
||||
|
||||
return (
|
||||
<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">
|
||||
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>
|
||||
)}
|
||||
|
||||
{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) => (
|
||||
<div key={f.id} className="border border-border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -207,15 +245,6 @@ function StepLessons({ control, register, errors }) {
|
||||
<LessonObjectives control={control} register={register} lessonIndex={i} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
return (
|
||||
@@ -370,6 +399,20 @@ function StepReview({ data, tierCategories, requirements }) {
|
||||
<SummaryRow label="Subscription" value={tierName || "No tier gate (open)"} />
|
||||
</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 ? (
|
||||
<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 ──────────────────────────────────────────────────────────────
|
||||
export default function AddLibraryUnit() {
|
||||
const navigate = useNavigate();
|
||||
const { createUnitFull, loading } = useLibrary();
|
||||
const { createUnitFull, attachLessonsToUnit, lessonsFlat, loading } = useLibrary();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { syncUnitRequirements } = useCourses();
|
||||
@@ -417,6 +460,8 @@ export default function AddLibraryUnit() {
|
||||
const [step, setStep] = useState(0);
|
||||
const [requirements, setRequirements] = useState([]);
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
const [existingLessons, setExistingLessons] = useState([]);
|
||||
const [attachLessonsOpen, setAttachLessonsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
@@ -473,6 +518,10 @@ export default function AddLibraryUnit() {
|
||||
const newUnitId = result?.data?.data?.unit_id;
|
||||
if (!newUnitId) return;
|
||||
|
||||
if (existingLessons.length > 0) {
|
||||
await attachLessonsToUnit(newUnitId, existingLessons.map((l) => l.lesson_id));
|
||||
}
|
||||
|
||||
if (requirements.length > 0) {
|
||||
const clean = requirements.map(({ _key, ...r }) => r);
|
||||
await syncUnitRequirements(null, newUnitId, clean);
|
||||
@@ -551,7 +600,16 @@ export default function AddLibraryUnit() {
|
||||
/>
|
||||
)}
|
||||
{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 && (
|
||||
<StepPageBuilder control={control} setValue={setValue} />
|
||||
@@ -560,7 +618,12 @@ export default function AddLibraryUnit() {
|
||||
<StepRequirements requirements={requirements} setRequirements={setRequirements} />
|
||||
)}
|
||||
{step === 4 && (
|
||||
<StepReview data={getValues()} tierCategories={tierCategories} requirements={requirements} />
|
||||
<StepReview
|
||||
data={getValues()}
|
||||
tierCategories={tierCategories}
|
||||
requirements={requirements}
|
||||
existingLessons={existingLessons}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -590,6 +653,19 @@ export default function AddLibraryUnit() {
|
||||
</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}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -55,8 +55,6 @@ import ArchivedLessonsList from '../pages/courses/lessons/ArchivedLessonsList'
|
||||
// Lesson Page Builder
|
||||
import LessonPageBuilder from '../pages/courses/lessons/LessonPageBuilder'
|
||||
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 ViewUnitQuiz from '../pages/courses/units/ViewUnitQuiz'
|
||||
|
||||
@@ -194,8 +192,6 @@ export const AdminRoutes = {
|
||||
{ path: 'add', element: <AddCourse /> },
|
||||
{ path: ':courseId/view', element: <ViewCourse /> },
|
||||
{ path: ':courseId/edit', element: <EditCourse /> },
|
||||
{ path: ":courseId/assessment", element: <CourseAssessment /> },
|
||||
{ path: ":courseId/assessment/view", element: <ViewAssessment /> },
|
||||
|
||||
// Categories
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user