mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
course,tasklist,task and completed validation
Signed-off-by: rgrgogu <obsequio.rus@gmail.com>
This commit is contained in:
@@ -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 } } : {},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user