course,tasklist,task and completed validation

Signed-off-by: rgrgogu <obsequio.rus@gmail.com>
This commit is contained in:
rgrgogu
2026-07-17 13:04:40 +08:00
parent 5b9f174718
commit 2e9ab5786c
22 changed files with 1345 additions and 117 deletions
@@ -148,13 +148,24 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
<div
key={lesson.id}
onClick={() => {
if (isFetching || !info?.unit?.course?.course_id) return;
navigate(`/course/${info.unit.course.course_id}/unit`, {
state: {
lessonId: info.lesson_id,
unitId: info.unit.unit_id,
...(taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {}),
},
if (isFetching || !info) return;
// Course-attached lesson — jump straight to it within the
// course's unit reader (existing behavior).
if (info.unit?.course?.course_id) {
navigate(`/course/${info.unit.course.course_id}/unit`, {
state: {
lessonId: info.lesson_id,
unitId: info.unit.unit_id,
...(taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {}),
},
});
return;
}
// Standalone lesson (junction revamp — no parent course, or no
// parent unit at all) — the course-scoped route can't resolve,
// so fall back to the standalone lesson page instead.
navigate(`/lessons/${lesson.reference_id}`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
}}
className={`bg-card rounded-2xl border p-4 flex flex-col gap-3 transition-all w-96 shrink-0 ${
@@ -194,9 +194,18 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
<div>
<h1
onClick={(e) => {
if (!info?.course?.course_id) return;
if (!info) return;
e.stopPropagation();
navigate(`/course/${info.course.course_id}/unit`, {
if (info.course?.course_id) {
navigate(`/course/${info.course.course_id}/unit`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
return;
}
// Standalone unit (junction revamp — no parent course) —
// the course-scoped route can't resolve, fall back to
// the standalone unit reader instead.
navigate(`/units/${unit.reference_id}/read`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
}}
@@ -248,8 +257,14 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
<Button
onClick={() => {
const info = details[selected?.reference_id];
if (!info?.course?.course_id) return;
navigate(`/course/${info.course.course_id}/unit`, {
if (!info) return;
if (info.course?.course_id) {
navigate(`/course/${info.course.course_id}/unit`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
return;
}
navigate(`/units/${selected.reference_id}/read`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
}}
+103 -4
View File
@@ -28,6 +28,7 @@ import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgress
import { PageMeta } from "@/contexts/MetadataContext";
import { toast } from "sonner";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { getTierColor, getContrastText } from "@/utils/tierColors";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
import { Tags } from "lucide-react";
@@ -681,8 +682,8 @@ const CourseDetails = () => {
)}
</div>
{progressSummary && progressSummary.lessons_total > 0 && (
<div className="flex flex-col gap-1.5 max-w-md">
<div className="flex items-center justify-between text-xs xs:text-white/80 lg:text-muted-foreground">
<div className="flex flex-col gap-1.5 max-w-xl">
<div className="flex items-center justify-between text-sm text-white mb-1">
<span>{progressSummary.lessons_completed} of {progressSummary.lessons_total} lessons complete</span>
<span>{progressSummary.percent}%</span>
</div>
@@ -725,7 +726,7 @@ const CourseDetails = () => {
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0">
<div className="flex flex-col xs:gap-12 lg:gap-12 flex-1 min-w-0">
<div className="space-y-4">
<div className="font-bold text-2xl">About this course</div>
<div className="font-bold text-2xl">About</div>
<div className="max-w-3xl space-y-4 text-muted-foreground lg:text-lg">
<p>{course?.description ?? ""}</p>
</div>
@@ -736,7 +737,7 @@ const CourseDetails = () => {
<div className="space-y-4">
{course?.objectives?.length > 0 && (
<div className="space-y-4">
<div className="font-bold text-2xl">What you will learn</div>
<div className="font-bold text-2xl">Learning Outcomes</div>
<ul className="max-w-3xl list-disc list-inside space-y-1 lg:text-lg text-muted-foreground">
{course.objectives.map((obj) => (
<li key={obj.objective_id}>{obj.text}</li>
@@ -746,6 +747,104 @@ const CourseDetails = () => {
)}
</div>
{/* Roles + Prerequisites */}
<div className="max-w-3xl space-y-6">
{/* Roles */}
<div className=" space-y-4">
<div className="space-y-2">
<div className="font-bold text-xl">Roles</div>
<p className="text-sm text-muted-foreground">Job roles this course qualifies you for</p>
</div>
{course?.roles?.length > 0 ? (
<div className="flex flex-wrap gap-2">
{course.roles.map((r) => (
<Badge key={r.role_id} variant="secondary" className="text-sm">
{r.text}
</Badge>
))}
</div>
) : (
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
No specific roles are required for this course.
</div>
)}
</div>
{/* Prerequisites */}
<div className="rounded-xl border bg-card p-6 space-y-4">
<div>
<div className="font-bold text-xl">Prerequisites</div>
<p className="text-sm text-muted-foreground">Complete these courses first</p>
</div>
{course?.prerequisites?.length > 0 ? (
<div className="space-y-2">
{course.prerequisites.map((p) => {
// Guarded = the prerequisite itself sits behind a paid tier —
// paint the whole row with that tier's actual admin-configured
// color (Tier Categories → Color) instead of a plain gray row.
// Uses the swatch hex directly (inline style) rather than a
// dynamic Tailwind class, since the color key is admin-defined
// at runtime and arbitrary bg-{key}-500 classes aren't
// guaranteed to survive Tailwind's build-time purge.
const tierInfo = p.subscription ? tierMap[p.subscription] : null;
const guarded = (tierInfo?.rank ?? 0) > 0 && !p.completed;
const tierColor = guarded ? getTierColor(tierInfo.color) : null;
const textColor = tierColor ? getContrastText(tierColor.swatch, tierInfo.color) : null;
return (
<div
key={p.prereq_id}
className={cn(
"flex items-center justify-between gap-3 py-3 px-3 rounded-lg",
p.completed && "bg-emerald-50 dark:bg-emerald-950/20",
!p.completed && !guarded && "bg-muted/40"
)}
style={guarded ? { backgroundColor: tierColor.swatch } : undefined}
>
<div className="flex items-center gap-3 min-w-0">
{p.completed ? (
<div className="w-6 h-6 rounded-full bg-emerald-500 flex items-center justify-center shrink-0">
<Check className="w-3.5 h-3.5 text-white" />
</div>
) : (
<div
className="size-4 rounded-full border-2 shrink-0"
style={{ borderColor: guarded ? textColor : undefined }}
/>
)}
<span
className={cn("font-medium truncate", !p.completed && !guarded && "text-muted-foreground")}
style={{ color: guarded ? textColor : undefined }}
>
{p.title ?? "—"}
</span>
</div>
<Badge
variant="secondary"
className={cn(
"shrink-0 border-0 gap-1",
p.completed
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400"
: !guarded && "text-muted-foreground"
)}
style={guarded ? { backgroundColor: `${textColor}1a`, color: textColor } : undefined}
>
{p.completed
? <><CheckCheck className="size-3.5" /> Completed</>
: <><LockIcon className="size-3.5" /> Locked</>}
</Badge>
</div>
);
})}
</div>
) : (
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
No prerequisites — this is a great course to start with.
</div>
)}
</div>
</div>
{/* Units — while content isn't ready, only Rewards is shown */}
<div className="space-y-4">
+32 -3
View File
@@ -1,17 +1,18 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate } from "react-router-dom";
import { useParams, useNavigate, useLocation } from "react-router-dom";
import {
House, SendHorizonal, CheckCheck, Check, Hourglass, Clock, Video,
House, SendHorizonal, CheckCheck, Check, Hourglass, Clock, Video, ListChecks,
} from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { PageMeta } from "@/contexts/MetadataContext";
import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
import LessonBlock from "../components/LessonBlock.jsx";
import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirementTypes";
import api from "@/utils/api.util";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -28,6 +29,20 @@ function formatDuration(seconds = 0) {
const LessonDetails = () => {
const { uuid } = useParams();
const navigate = useNavigate();
const location = useLocation();
// Task context: state-first, endpoint fallback — same pattern as UnitList.jsx.
// If navigated here from a task's requirement card, taskCtx is in location.state;
// if the user opened this lesson directly (e.g. from /lessons), fetch it instead,
// so the "Task mode" banner still shows up when this lesson is a task requirement.
const [taskCtx, setTaskCtx] = useState(null);
useEffect(() => {
const stateCtx = location.state?.taskCtx;
if (stateCtx) { setTaskCtx(stateCtx); return; }
api.get(`/client/courses/lesson/uuid/${uuid}/task-context`)
.then(({ data }) => { if (data?.data?.has_task) setTaskCtx(data.data); })
.catch(() => {}); // non-critical — silently swallow
}, [uuid]); // eslint-disable-line react-hooks/exhaustive-deps
const {
getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson,
@@ -104,6 +119,20 @@ const LessonDetails = () => {
<div className="flex flex-col gap-6 flex-1 min-w-0 xs:px-4 xs:py-8 lg:px-16 lg:py-10">
<AppBreadcrumb items={items} />
{taskCtx?.has_task && (
<div className={`flex items-center gap-2 text-white text-xs font-medium px-4 py-1.5 rounded-md shadow-sm w-fit transition-colors ${
hasCompleted ? 'bg-green-600' : 'bg-blue-600'
}`}>
<ListChecks className="size-3.5 shrink-0" />
<span>
{hasCompleted
? 'Lesson complete — tracking finished'
: 'Task mode — progress is being tracked automatically'
}
</span>
</div>
)}
<div className="flex flex-col gap-3 max-w-2xl">
<h1 className="font-bold xs:text-2xl lg:text-3xl">{lesson.title}</h1>
<p className="text-muted-foreground">{lesson.description ?? ""}</p>
+32 -3
View File
@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect, useRef } from "react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate, useLocation, useBlocker } from "react-router-dom";
import { House, TableOfContents, ArrowRight, ClipboardList, Lock, CheckCircle2, Circle, Zap, ChevronsLeft, ChevronsRight, Hourglass } from "lucide-react";
import { House, TableOfContents, ArrowRight, ClipboardList, Lock, CheckCircle2, Circle, Zap, ChevronsLeft, ChevronsRight, Hourglass, ListChecks } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
@@ -12,6 +12,7 @@ import { useLibrary } from "@/contexts/ClientLibraryContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import api from "@/utils/api.util";
// ─── Sidebar (single unit — flat lessons + quiz, no unit-accordion nesting) ──
@@ -87,6 +88,19 @@ const UnitReader = () => {
// Tracks which lessons have been marked completed this session to avoid duplicate calls
const completedSessionRef = useRef(new Set());
// ── Task context: state-first, endpoint fallback — same pattern as UnitList.jsx.
// If navigated here from a task's requirement card, taskCtx is in location.state;
// if the user opened this unit directly (e.g. from /units), fetch it instead, so
// the "Task mode" banner still shows up when this unit is a task requirement.
const [taskCtx, setTaskCtx] = useState(null);
useEffect(() => {
const stateCtx = location.state?.taskCtx;
if (stateCtx) { setTaskCtx(stateCtx); return; }
api.get(`/client/courses/unit/uuid/${uuid}/task-context`)
.then(({ data }) => { if (data?.data?.has_task) setTaskCtx(data.data); })
.catch(() => {}); // non-critical — silently swallow
}, [uuid]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Local UI state ──────────────────────────────────────────────────────
const [selectedLessonId, setSelectedLessonId] = useState(null);
const [selectedQuizId, setSelectedQuizId] = useState(null);
@@ -440,8 +454,23 @@ const UnitReader = () => {
)}
</div>
{/* ── Task-mode banner ─────────────────────────────────────────── */}
{taskCtx?.has_task && (
<div className={`fixed xs:top-[124px] lg:top-[112px] left-0 right-0 z-30 flex items-center gap-2 text-white text-xs font-medium px-4 py-1.5 shadow-sm transition-colors ${
unitDetail?.is_completed ? 'bg-green-600' : 'bg-blue-600'
}`}>
<ListChecks className="size-3.5 shrink-0" />
<span>
{unitDetail?.is_completed
? 'Unit complete — tracking finished'
: 'Task mode — progress is being tracked automatically'
}
</span>
</div>
)}
{/* ── Desktop sidebar ── */}
<div className="hidden lg:flex flex-row fixed top-[112px] bottom-0 left-0 z-30 bg-muted border-r">
<div className={`hidden lg:flex flex-row fixed ${taskCtx?.has_task ? "top-[140px]" : "top-[112px]"} bottom-0 left-0 z-30 bg-muted border-r`}>
<div className="w-14 shrink-0 flex flex-col items-center pt-3">
<Button
variant="ghost"
@@ -471,7 +500,7 @@ const UnitReader = () => {
</div>
{/* ── Main content ── */}
<div className={`mt-32 ${showSidebarContent ? "lg:ml-80" : "lg:ml-14"} p-4 md:p-6 min-h-screen`}>
<div className={`${taskCtx?.has_task ? "xs:mt-42 lg:mt-36" : "mt-32"} ${showSidebarContent ? "lg:ml-80" : "lg:ml-14"} p-4 md:p-6 min-h-screen`}>
<div className="relative w-full h-full">
{selectedQuizId ? (
<QuizBlock
+52 -18
View File
@@ -18,6 +18,9 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Progress } from '@/components/ui/progress';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
import {
House, Calendar, AlertTriangle, Check, ArrowRight, ListChecks, LayoutList,
LaptopMinimal, Table, Lock,
@@ -75,17 +78,17 @@ const TaskStatusBadge = ({ task }) => {
};
// ─── Task card ────────────────────────────────────────────────────────────────
const TaskCard = ({ task, onClick, locked }) => {
const TaskCard = ({ task, onClick, locked, lockedBy, onLockedClick }) => {
const reqCount = task.requirements?.length ?? 0;
return (
<div
onClick={() => !locked && onClick()}
onClick={() => (locked ? onLockedClick(lockedBy) : onClick())}
className={cn(
'border bg-card rounded-lg flex flex-col transition-colors',
'border bg-card rounded-lg flex flex-col transition-colors cursor-pointer',
locked
? 'opacity-60 cursor-not-allowed'
: 'cursor-pointer hover:border-blue-400 dark:hover:border-blue-500',
? 'opacity-60 hover:border-muted-foreground/30'
: 'hover:border-blue-400 dark:hover:border-blue-500',
task.has_completed && 'opacity-90',
)}
>
@@ -101,17 +104,15 @@ const TaskCard = ({ task, onClick, locked }) => {
: <TaskStatusBadge task={task} />
}
</div>
{task.description && (
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
{task.description}
</p>
)}
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
{task.description || 'No information details provided'}
</p>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-auto pt-1 [&_svg]:size-3.5">
<Calendar />
{task.deadline ? `Due ${formatDate(task.deadline)}` : 'No due date'}
</div>
{locked && (
<p className="text-xs text-muted-foreground">Complete the earlier required tasks to unlock.</p>
<p className="text-xs text-muted-foreground">Tap to see what's required to unlock this task.</p>
)}
</div>
<div className="px-4 py-2.5 border-t flex items-center justify-between">
@@ -167,9 +168,10 @@ const ViewTaskDetails = () => {
const { taskList, loading, fetchTaskList } = useTask();
const { group, fetchGroup } = useGroup();
const [view, setView] = useState('grid');
const [activeTab, setActiveTab] = useState('tab-ongoing');
const [allTasks, setAllTasks] = useState([]);
const [view, setView] = useState('grid');
const [activeTab, setActiveTab] = useState('tab-ongoing');
const [allTasks, setAllTasks] = useState([]);
const [lockedInfo, setLockedInfo] = useState(null); // task names blocking the last-clicked locked card
// ── Fetch group info once ─────────────────────────────────────────────────
useEffect(() => {
@@ -193,12 +195,13 @@ const ViewTaskDetails = () => {
.catch(() => {});
}, [groupId, taskListId]);
// ── Sequencing lock — same pattern as UnitList.jsx's quiz lock ───────────
// ── Sequencing/prerequisite lock — computed server-side (task.locked,
// task.lockedBy), since an explicit prerequisite graph can't be expressed
// as a one-line client-side scan the way the old order_index-only rule could.
const lockedTaskIds = new Set(
allTasks
.filter((t, i, arr) => arr.slice(0, i).some((prev) => prev.is_required && !prev.has_completed))
.map((t) => t.task_id)
allTasks.filter((t) => t.locked).map((t) => t.task_id)
);
const lockedByById = new Map(allTasks.map((t) => [t.task_id, t.lockedBy ?? []]));
const completedCount = allTasks.filter((t) => t.has_completed).length;
@@ -250,6 +253,8 @@ const ViewTaskDetails = () => {
key={task.task_id}
task={task}
locked={lockedTaskIds.has(task.task_id)}
lockedBy={lockedByById.get(task.task_id) ?? []}
onLockedClick={setLockedInfo}
onClick={() => navigate(`task/${task.task_id}`)}
/>
))}
@@ -351,6 +356,35 @@ const ViewTaskDetails = () => {
</div>
</div>
{/* ── Locked task requirement dialog ── */}
<Dialog open={!!lockedInfo} onOpenChange={(open) => !open && setLockedInfo(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Lock className="size-4" /> Task locked
</DialogTitle>
<DialogDescription>
{lockedInfo?.length
? `Complete the following task${lockedInfo.length !== 1 ? 's' : ''} first to unlock this one:`
: 'Complete the earlier required tasks first to unlock this one.'}
</DialogDescription>
</DialogHeader>
{!!lockedInfo?.length && (
<ul className="flex flex-col gap-2">
{lockedInfo.map((t) => (
<li
key={t.task_id}
className="flex items-center gap-2 text-sm bg-muted rounded-md px-3 py-2"
>
<ListChecks className="size-3.5 text-muted-foreground shrink-0" />
{t.name}
</li>
))}
</ul>
)}
</DialogContent>
</Dialog>
</div>
);
};