added more things

This commit is contained in:
rgrgogu
2026-08-03 17:19:12 +08:00
parent 736e5f0a6f
commit 3c7da8944f
14 changed files with 422 additions and 495 deletions
@@ -75,7 +75,16 @@ export default function CreateLessonDialog({ open, onOpenChange, courseId, unitI
<DialogTitle>New Lesson</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onValid)} className="space-y-4">
<form
onSubmit={(e) => {
// Dialog content is portalled out of the DOM, but React still bubbles
// synthetic events through the React tree — without this, submitting
// here also fires the wizard's outer <form onSubmit>, advancing the step.
e.stopPropagation();
handleSubmit(onValid)(e);
}}
className="space-y-4"
>
<div className="space-y-1.5">
<Label htmlFor="lesson_title">
Title <span className="text-destructive">*</span>
@@ -93,7 +93,16 @@ export default function CreateUnitDialog({ open, onOpenChange, courseId, nextOrd
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onValid)} className="space-y-4">
<form
onSubmit={(e) => {
// Dialog content is portalled out of the DOM, but React still bubbles
// synthetic events through the React tree — without this, submitting
// here also fires the wizard's outer <form onSubmit>, advancing the step.
e.stopPropagation();
handleSubmit(onValid)(e);
}}
className="space-y-4"
>
{step === 0 && (
<>
<div className="space-y-1.5">
@@ -205,15 +205,15 @@ export default function RoadmapBuilder({ units, onUnitsChange }) {
<div className="flex items-center gap-2 pt-1">
<Button
type="button" variant="outline" size="sm" className="h-7 text-xs"
onClick={() => setCreateLessonUnitKey(unit.key)}
onClick={() => setAttachLessonUnitKey(unit.key)}
>
<Plus className="h-3 w-3 mr-1" /> New Lesson
<Link2 className="h-3 w-3 mr-1" /> Select Lesson
</Button>
<Button
type="button" variant="outline" size="sm" className="h-7 text-xs"
onClick={() => setAttachLessonUnitKey(unit.key)}
onClick={() => setCreateLessonUnitKey(unit.key)}
>
<Link2 className="h-3 w-3 mr-1" /> Attach Existing Lesson
<Plus className="h-3 w-3 mr-1" /> Create Lesson
</Button>
</div>
</div>
@@ -1,10 +1,17 @@
// AttachLessonsDialog — pick existing library Lessons and attach them to a Unit.
// Junction revamp: attaching creates unit_lessons rows; the Lessons stay standalone.
//
// Clicking a row previews that lesson's existing Page Builder blocks on the right,
// independent of the checkbox — so admins can see what they're attaching before
// committing. Preview fetches go through `api` directly rather than the shared
// AdminLibraryContext `fetchLesson`, since that setter writes into a single global
// `lesson` slot that other mounted pages may depend on.
import { useEffect, useMemo, useState } from "react";
import { Search, Link2 } from "lucide-react";
import { Search, Link2, Eye } from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import api from "@/utils/api.util";
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle,
@@ -16,20 +23,49 @@ import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { formatDuration } from "@/utils/timestamp.util";
import { PreviewChrome, PreviewContent } from "../courses/LessonsPreview";
export default function AttachLessonsDialog({ open, onOpenChange, attachedLessonIds = [], onAttach, loading }) {
const { lessonsFlat, fetchLessonsFlat, loading: libraryLoading } = useLibrary();
const [query, setQuery] = useState("");
const [selected, setSelected] = useState([]);
const [previewLessonId, setPreviewLessonId] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewCache, setPreviewCache] = useState({});
useEffect(() => {
if (open) {
setSelected([]);
setQuery("");
setPreviewLessonId(null);
setPreviewCache({});
fetchLessonsFlat();
}
}, [open, fetchLessonsFlat]);
useEffect(() => {
if (!previewLessonId || previewLessonId in previewCache) return;
let cancelled = false;
setPreviewLoading(true);
api.get(`/admin/lessons/${previewLessonId}`)
.then((resp) => {
if (cancelled) return;
setPreviewCache((prev) => ({ ...prev, [previewLessonId]: resp.data?.data?.data ?? null }));
})
.catch(() => {
if (cancelled) return;
setPreviewCache((prev) => ({ ...prev, [previewLessonId]: null }));
})
.finally(() => {
if (!cancelled) setPreviewLoading(false);
});
return () => { cancelled = true; };
}, [previewLessonId, previewCache]);
const previewLesson = previewLessonId ? previewCache[previewLessonId] : null;
const previewBlocks = previewLesson?.page?.blocks ?? [];
const attachedSet = useMemo(
() => new Set(attachedLessonIds.map(String)),
[attachedLessonIds]
@@ -55,16 +91,19 @@ export default function AttachLessonsDialog({ open, onOpenChange, attachedLesson
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Link2 className="h-4 w-4" /> Select Lessons
</DialogTitle>
<DialogDescription>
Lessons live independently in the library — attaching adds them to this unit without copying.
Click a lesson to preview its content.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-[280px_1fr]">
<div className="space-y-2 min-w-0">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
@@ -75,44 +114,82 @@ export default function AttachLessonsDialog({ open, onOpenChange, attachedLesson
/>
</div>
<ScrollArea className="h-64 rounded-md border">
<ScrollArea className="h-80 rounded-md border">
{libraryLoading ? (
<div className="flex items-center justify-center h-full py-10">
<Spinner className="h-5 w-5" />
</div>
) : candidates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
<p className="text-sm text-muted-foreground text-center py-10 px-3">
{query ? "No lessons match your search." : "Every library lesson is already attached."}
</p>
) : (
<div className="divide-y">
{candidates.map((l) => (
<label
<div
key={l.lesson_id}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/60 cursor-pointer"
onClick={() => {
toggle(l.lesson_id);
setPreviewLessonId(l.lesson_id);
}}
className={`flex items-center gap-3 px-3 py-2.5 cursor-pointer select-none hover:bg-muted/60 ${
previewLessonId === l.lesson_id ? "bg-muted" : ""
}`}
>
<Checkbox
checked={selected.includes(l.lesson_id)}
onCheckedChange={() => toggle(l.lesson_id)}
onCheckedChange={() => {
toggle(l.lesson_id);
setPreviewLessonId(l.lesson_id);
}}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium w-64 truncate">{l.title}</p>
<p className="text-sm font-medium truncate">{l.title}</p>
<p className="text-xs text-muted-foreground truncate">
{formatDuration(l.duration_seconds ?? 0)}
</p>
</div>
{Number(l.unit_count) > 0 ? (
<Badge variant="secondary" className="text-xs shrink-0">
in {l.unit_count} unit{Number(l.unit_count) === 1 ? "" : "s"}
{l.unit_count}
</Badge>
) : (
<Badge variant="outline" className="text-xs shrink-0 text-muted-foreground">standalone</Badge>
<Badge variant="outline" className="text-xs shrink-0 text-muted-foreground">new</Badge>
)}
</label>
</div>
))}
</div>
)}
</ScrollArea>
</div>
<div className="min-w-0">
<ScrollArea className="h-80 rounded-md border">
<div className="p-3">
{!previewLessonId ? (
<div className="flex flex-col items-center justify-center gap-2 h-64 text-sm text-muted-foreground">
<Eye className="h-6 w-6 opacity-20" />
<p>Click a lesson to preview its content.</p>
</div>
) : previewLoading ? (
<div className="flex items-center justify-center h-64">
<Spinner className="h-5 w-5" />
</div>
) : (
<PreviewChrome title={previewLesson?.title}>
<PreviewContent
lesson={previewLesson}
blocks={previewBlocks}
showHeader={false}
empty="No content blocks yet."
/>
</PreviewChrome>
)}
</div>
</ScrollArea>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { useAdminTask } from '@/contexts/AdminTaskContext';
import { useAuth } from '@/contexts/AuthContext';
@@ -8,24 +8,13 @@ import DataTable from '@/components/generic/Table/DataTable';
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { ArchiveDialog } from '@/components/generic/Dialogs/ArchiveDialog';
import { RestoreDialog } from '@/components/generic/Dialogs/RestoreDialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Pencil, Users, ListTodo, House, TriangleAlert } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Spinner } from '@/components/ui/spinner';
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
import { formatDate } from '@/utils/table.util';
import { TriangleAlert } from 'lucide-react';
import { buildDataColumns, columnPinning } from '@/modules/admin/config/task_list/task/columns.config';
import { buildToolbarActions } from '@/modules/admin/config/task_list/task/toolbar.config';
@@ -34,15 +23,14 @@ import { buildRowActions } from '@/modules/admin/config/task_list/task/rowAction
import { getTimestamp } from '@/utils/timestamp.util';
import { formatGeneratedBy } from '@/utils/generatedBy.util';
export default function Tasks() {
export default function TasksTable({ taskListId }) {
const navigate = useNavigate();
const { taskListId } = useParams();
const { user: currentUser } = useAuth();
const {
taskList, tasks, attributes, pagination, loading,
fetchTaskList, fetchTasks, fetchArchivedTasks,
tasks, attributes, pagination, loading,
fetchTasks, fetchArchivedTasks,
archiveTask, restoreTask, fetchTaskFieldValues,
bulkArchiveTasks, bulkRestoreTasks, reorderTasks,
} = useAdminTask();
@@ -52,14 +40,12 @@ export default function Tasks() {
const [restoreTarget, setRestoreTarget] = useState(null);
const [bulkArchiveIds, setBulkArchiveIds] = useState(null);
const [bulkRestoreIds, setBulkRestoreIds] = useState(null);
const [showGroupsDialog, setShowGroupsDialog] = useState(false);
const tableRefsRef = useRef({
getFilters: () => [], getSort: () => [], resetSelection: () => { }, tableInstance: null,
});
useEffect(() => {
fetchTaskList(taskListId);
fetchTasks(taskListId, { page: 1, limit: 10 });
}, [taskListId]);
@@ -134,7 +120,6 @@ export default function Tasks() {
getTableInstance: () => tableRefsRef.current.tableInstance,
});
// ── Selection ─────────────────────────────────────────────────────────────
const selectionActions = buildSelectionActions({
showArchived,
onBulkArchive: (ids) => setBulkArchiveIds(ids),
@@ -144,110 +129,8 @@ export default function Tasks() {
const columns = useMemo(() => buildDataColumns(attributes, rowActions), [attributes, rowActions]);
// ── Derived values ────────────────────────────────────────────────────────
const assignedGroups = taskList?.groups ?? [];
const totalTasks = pagination?.totalRecords ?? 0;
const hasOverflow = assignedGroups.length > 1;
const formattedCreated = taskList?.createdAt ? formatDate(taskList.createdAt) : '—';
const formattedUpdated = taskList?.updatedAt ? formatDate(taskList.updatedAt) : '—';
const breadcrumbs = [
{ label: 'Home', icon: <House className="size-4" />, to: '/admin' },
{ label: 'Task List', to: '/admin/taskList' },
{ label: 'Tasks' },
];
return (
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
{/* ── Breadcrumb ────────────────────────────────────────────────── */}
<div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={breadcrumbs} />
</div>
{/* ── Detail card ───────────────────────────────────────────────── */}
<div className="bg-card border rounded-xl p-5 flex flex-col gap-4 w-full mb-6">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1 min-w-0">
{/* Name */}
{taskList
? <h1 className="text-lg font-medium leading-none">{taskList.name}</h1>
: <Skeleton className="h-5 w-48" />
}
{/* Description */}
{taskList
? <p className="text-sm text-muted-foreground mt-1">{taskList.description ?? '—'}</p>
: <Skeleton className="h-4 w-72 mt-1" />
}
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{/* Total tasks */}
<div className="bg-muted rounded-lg px-3 py-2">
<span className="block text-[11px] uppercase tracking-wide text-muted-foreground mb-1">
Tasks
</span>
<span className="text-sm font-medium flex items-center gap-1.5">
<ListTodo className="size-3.5 text-muted-foreground" />
{taskList ? totalTasks : <Skeleton className="h-4 w-8 inline-block" />}
</span>
</div>
{/* Assigned groups */}
<div className="bg-muted rounded-lg px-3 py-2">
<span className="block text-[11px] uppercase tracking-wide text-muted-foreground mb-1">
Groups
</span>
{taskList ? (
taskList.group_count > 0 ? (
<div className="font-medium flex items-center gap-2">
<Users className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-medium">
{taskList.group_count}
</span>
{hasOverflow && (
<button
type="button"
onClick={() => setShowGroupsDialog(true)}
className="text-xs text-primary hover:underline underline-offset-2 shrink-0"
>
+{taskList.group_count - 1} more
</button>
)}
</div>
) : 0
) : (
<Skeleton className="h-4 w-8 inline-block" />
)}
</div>
{/* Created */}
<div className="bg-muted rounded-lg px-3 py-2">
<span className="block text-[11px] uppercase tracking-wide text-muted-foreground mb-1">
Created
</span>
<span className="text-sm font-medium">
{taskList ? formattedCreated : <Skeleton className="h-4 w-20 inline-block" />}
</span>
</div>
{/* Last updated */}
<div className="bg-muted rounded-lg px-3 py-2">
<span className="block text-[11px] uppercase tracking-wide text-muted-foreground mb-1">
Last Updated
</span>
<span className="text-sm font-medium">
{taskList ? formattedUpdated : <Skeleton className="h-4 w-20 inline-block" />}
</span>
</div>
</div>
</div>
{/* ── Tasks DataTable ───────────────────────────────────────────── */}
<>
<DataTable
columns={columns}
data={tasks}
@@ -259,6 +142,8 @@ export default function Tasks() {
selectionActions={selectionActions}
columnPinning={columnPinning}
onRefsReady={handleRefsReady}
recordLabel="task"
emptyMessage="No tasks in this list yet."
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
@@ -271,29 +156,6 @@ export default function Tasks() {
)}
/>
{/* ── All Groups Dialog ─────────────────────────────────────────── */}
<Dialog open={showGroupsDialog} onOpenChange={setShowGroupsDialog}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
Assigned Groups
<Badge variant="secondary" className="ml-1 text-xs">
{assignedGroups.length}
</Badge>
</DialogTitle>
</DialogHeader>
<div className="flex flex-wrap gap-2 pt-1">
{assignedGroups.map((g) => (
<Badge key={g.group_id} variant="secondary" className="gap-1.5 text-xs py-1 px-2">
<Users className="h-3 w-3" />
{g.name}
</Badge>
))}
</div>
</DialogContent>
</Dialog>
{/* ── Single archive ────────────────────────────────────────────── */}
<AlertDialog open={!!archiveTarget} onOpenChange={(v) => !v && setArchiveTarget(null)}>
<AlertDialogContent className="sm:max-w-sm">
@@ -366,7 +228,6 @@ export default function Tasks() {
loading={loading}
onSuccess={afterMutation}
/>
</div>
</>
);
}
@@ -1,4 +1,4 @@
import { Eye, Archive, ArchiveRestore, NotebookPen, Info } from "lucide-react";
import { Eye, Archive, ArchiveRestore } from "lucide-react";
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
return [
@@ -8,14 +8,6 @@ export function buildRowActions({ navigate, onArchive, onRestore, showArchived }
icon: <Eye className="size-4" />,
onClick: (row) => navigate(`${row.task_list_id}/view`),
},
{
key: "tasks",
label: "View Tasks",
icon: <NotebookPen className="size-4" />,
onClick: (row) => navigate(`${row.task_list_id}/tasks`),
separator: true,
className: "text-sky-800"
},
{
key: "archive",
label: "Archive",
@@ -7,7 +7,7 @@ import { nanoid } from "nanoid";
import {
ArrowLeft, ChevronLeft, ChevronRight, Check,
FileText, BookOpen, LayoutTemplate, ClipboardCheck, ListChecks,
Plus, Trash2, Link2,
Plus, Trash2, Link2, Eye,
} from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
@@ -250,12 +250,13 @@ function StepLessons({ control, register, errors, existingLessons, onRemoveExist
}
// ─── Step 3 — Page Builder ─────────────────────────────────────────────────────
function StepPageBuilder({ control, setValue }) {
function StepPageBuilder({ control, setValue, existingLessons }) {
const lessons = useWatch({ control, name: "lessons" }) ?? [];
const [rawIndex, setActiveIndex] = useState(0);
const [drawerOpen, setDrawerOpen] = useState(false);
const [viewingExisting, setViewingExisting] = useState(null);
if (lessons.length === 0) {
if (lessons.length === 0 && existingLessons.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Add at least one lesson in the previous step to build its page content here.
@@ -288,6 +289,29 @@ function StepPageBuilder({ control, setValue }) {
return (
<div className="space-y-3">
{existingLessons.map((l) => (
<div key={l.lesson_id} className="flex items-center justify-between border border-border rounded-lg p-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{l.title}</p>
<Badge variant="outline" className="text-[10px] shrink-0">existing</Badge>
</div>
<p className="text-xs text-muted-foreground">
{(l.blocks?.length ?? 0)} block{(l.blocks?.length ?? 0) !== 1 ? "s" : ""} · content lives in the Lessons library
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setViewingExisting(l)}
>
<Eye className="h-4 w-4 mr-1.5" />
View Content
</Button>
</div>
))}
{lessons.map((l, i) => (
<div key={i} className="flex items-center justify-between border border-border rounded-lg p-4">
<div>
@@ -345,13 +369,13 @@ function StepPageBuilder({ control, setValue }) {
<div className="space-y-3">
<div className="text-sm font-medium text-muted-foreground">Live Preview</div>
<PreviewChrome title={activeLesson.title}>
<PreviewChrome title={activeLesson?.title}>
<div className="p-6 space-y-5 min-h-[300px]">
<PreviewContent
lesson={{
title: activeLesson.title,
description: activeLesson.description,
objectives: (activeLesson.objectives ?? []).map((o, oi) => ({ objective_id: oi, text: o.value })),
title: activeLesson?.title,
description: activeLesson?.description,
objectives: (activeLesson?.objectives ?? []).map((o, oi) => ({ objective_id: oi, text: o.value })),
}}
blocks={blocks}
empty="Your content will appear here as you build."
@@ -369,6 +393,37 @@ function StepPageBuilder({ control, setValue }) {
</DrawerFooter>
</DrawerContent>
</Drawer>
<Drawer open={!!viewingExisting} onOpenChange={(open) => !open && setViewingExisting(null)} shouldScaleBackground>
<DrawerContent className="data-[vaul-drawer-direction=bottom]:max-h-[90vh]">
<DrawerHeader className="border-b text-left">
<DrawerTitle>Page Content</DrawerTitle>
<DrawerDescription>{viewingExisting?.title} · attached from the Lessons library</DrawerDescription>
</DrawerHeader>
<div className="flex-1 overflow-y-auto p-4">
<PreviewChrome title={viewingExisting?.title}>
<div className="p-6 space-y-5 min-h-[300px]">
<PreviewContent
lesson={{
title: viewingExisting?.title,
description: viewingExisting?.description,
objectives: viewingExisting?.objectives ?? [],
}}
blocks={viewingExisting?.blocks ?? []}
empty="This lesson has no page content yet."
/>
</div>
</PreviewChrome>
</div>
<DrawerFooter className="border-t flex-row justify-end">
<DrawerClose asChild>
<Button type="button">Close</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</div>
);
}
@@ -612,7 +667,7 @@ export default function AddLibraryUnit() {
/>
)}
{step === 2 && (
<StepPageBuilder control={control} setValue={setValue} />
<StepPageBuilder control={control} setValue={setValue} existingLessons={existingLessons} />
)}
{step === 3 && (
<StepRequirements requirements={requirements} setRequirements={setRequirements} />
@@ -657,12 +712,33 @@ export default function AddLibraryUnit() {
open={attachLessonsOpen}
onOpenChange={setAttachLessonsOpen}
attachedLessonIds={existingLessons.map((l) => l.lesson_id)}
onAttach={(lessonIds) => {
onAttach={async (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]);
.filter(Boolean);
// Fetch each lesson's page content so it can be previewed in the
// Page Builder step — the flat list used by the picker only carries
// title/duration, not blocks.
const withContent = await Promise.all(
picked.map(async (l) => {
try {
const { data } = await api.get(`/admin/lessons/${l.lesson_id}`);
const full = data?.data?.data;
return {
lesson_id: l.lesson_id,
title: l.title,
description: full?.description ?? "",
objectives: full?.objectives ?? [],
blocks: full?.page?.blocks ?? [],
};
} catch {
return { lesson_id: l.lesson_id, title: l.title, blocks: [] };
}
})
);
setExistingLessons((prev) => [...prev, ...withContent]);
}}
/>
+152 -248
View File
@@ -1,210 +1,97 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useAdminTask } from '@/contexts/AdminTaskContext';
import { useDateFormat } from '@/hooks/useDateFormat';
import { PageMeta } from '@/contexts/MetadataContext';
import TasksTable from '../../components/task_list/TasksTable';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import {
ArrowLeft, Pencil, Users, ClipboardList, FileText,
Link2, Upload, BookOpen, BookMarked, FileCheck2,
CalendarClock, Equal, Tag, Globe, Copy, File, Bookmark, GitBranch,
ArrowLeft, Pencil, Users, ListTodo, Info,
NotebookPen, FileText, CalendarClock,
} from 'lucide-react';
// ─── All styling uses shadcn tokens — only label/icon differs per type
const REQUIREMENT_CONFIG = {
visit_link: { label: 'Visit Link', badgeLabel: 'Link', Icon: Link2 },
upload_file: { label: 'Upload File', badgeLabel: 'Upload', Icon: Upload },
read_course: { label: 'Read Course', badgeLabel: 'Course', Icon: BookOpen },
read_unit: { label: 'Read Unit', badgeLabel: 'Unit', Icon: BookMarked },
read_lesson: { label: 'Read Lesson', badgeLabel: 'Lesson', Icon: FileCheck2 },
};
// ─── Helpers ────────────────────────────────────────────────────────────────
// ─── Label / value row — fully themed by shadcn tokens ───────────────────────
function MetaRow({ icon: Icon, label, children }) {
function InfoRow({ label, children }) {
return (
<div className="flex items-center justify-between gap-3 px-3.5 py-2.5 border-b border-border last:border-b-0">
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0">
<Icon className="size-3.5" />
{label}
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
<span className="text-sm font-medium">{children ?? <span className="text-muted-foreground italic">—</span>}</span>
</div>
<div className="text-xs text-foreground text-right min-w-0">
);
}
function SectionCard({ icon: Icon, title, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-sm font-semibold">{title}</h2>
</div>
<Separator />
{children}
</div>
</div>
);
}
// ─── Requirement card ─────────────────────────────────────────────────────────
function RequirementCard({ req }) {
const cfg = REQUIREMENT_CONFIG[req.type] ?? {
label: req.type, badgeLabel: req.type, Icon: FileText, accent: 'text-muted-foreground',
};
const { Icon } = cfg;
function LoadingSkeleton() {
return (
<div className="border border-border rounded-xl bg-background">
{/* Header */}
<div className="flex items-center gap-2.5 px-3.5 py-3 border-b border-border">
<div className="size-8 rounded-lg flex items-center justify-center shrink-0 bg-muted">
<Icon className="size-4 text-foreground" />
</div>
<span className="text-sm font-medium flex-1 text-foreground">
{cfg.label}
</span>
<span className="text-[10px] font-medium px-2.5 py-0.5 rounded-full border border-border bg-muted text-muted-foreground">
{cfg.badgeLabel}
</span>
</div>
{/* Rows */}
{req.type === 'visit_link' && (
<>
{req.link_label && (
<MetaRow icon={Tag} label="Label">
<span>{req.link_label}</span>
</MetaRow>
)}
{req.link_url && (
<MetaRow icon={Globe} label="URL">
<a
href={/^https?:\/\//i.test(req.link_url) ? req.link_url : `https://${req.link_url}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline underline-offset-2 truncate block max-w-[240px] hover:opacity-80 transition-opacity"
>
{req.link_url}
</a>
</MetaRow>
)}
</>
)}
{req.type === 'upload_file' && (
<>
{req.max_file_count != null && (
<MetaRow icon={Copy} label="Max files">
<span className="font-medium">{req.max_file_count}</span>
</MetaRow>
)}
{req.allowed_file_types?.length > 0 && (
<MetaRow icon={File} label="Allowed types">
<span>{req.allowed_file_types.join(', ')}</span>
</MetaRow>
)}
</>
)}
{['read_course', 'read_unit', 'read_lesson'].includes(req.type) && req.reference_label && (
<MetaRow icon={Bookmark} label={cfg.badgeLabel}>
<span className="truncate block max-w-[220px]">{req.reference_label}</span>
</MetaRow>
)}
<div className="space-y-5">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
}
// ─── Requirements section ─────────────────────────────────────────────────────
function TaskRequirementsSection({ requirements = [] }) {
const sorted = [...requirements].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
return (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Requirements
</p>
{sorted.length > 0 ? (
sorted.map((req) => (
<RequirementCard key={req.requirement_id} req={req} />
))
) : (
<p className="text-xs text-muted-foreground italic">No other requirements.</p>
)}
</div>
);
}
// ─── Tab: Details ───────────────────────────────────────────────────────────
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ViewTaskList() {
const navigate = useNavigate();
const { taskListId } = useParams();
const { fetchTaskList } = useAdminTask();
function DetailsTab({ taskList }) {
const { fmtDateTime } = useDateFormat();
const [taskList, setTaskList] = useState(null);
useEffect(() => {
fetchTaskList(taskListId).then((data) => {
if (!data) return;
setTaskList(data);
});
}, [taskListId]);
if (!taskList) return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-4">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-6 w-64" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
if (!taskList) return <LoadingSkeleton />;
const groups = taskList.groups ?? [];
const tasks = taskList.tasks ?? [];
const taskCount = taskList.tasks?.length ?? 0;
return (
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="mx-auto space-y-4">
{/* Header */}
<div className="flex items-center justify-between w-full">
<div className="flex gap-2 items-center">
<Button
variant="ghost"
className=""
onClick={() => navigate('/admin/taskList')}
>
<ArrowLeft className="size-4" />
</Button>
<div className="space-y-1">
<h1 className="text-xl font-semibold">{taskList.name}</h1>
<div className="space-y-5">
<SectionCard icon={FileText} title="Basic Information">
<div className="space-y-4">
<InfoRow label="Name">{taskList.name}</InfoRow>
{taskList.description && (
<p className="text-sm text-muted-foreground leading-relaxed">
<InfoRow label="Description">
<span className="whitespace-pre-wrap text-sm font-normal text-foreground">
{taskList.description}
</p>
</span>
</InfoRow>
)}
</div>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 gap-1.5"
onClick={() => navigate(`/admin/taskList/${taskListId}/edit`)}
>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
</div>
</SectionCard>
<div className="lg:w-2xl rounded-lg border border-border bg-card text-card-foreground shadow-sm">
<div className="p-6 space-y-6">
{/* Assigned Groups */}
<div className="space-y-4">
<div className="flex items-center gap-1.5 text-sm font-medium">
<Equal className="size-4" />
Assigned Groups
<SectionCard icon={ListTodo} title="Stats">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Tasks">
<div className="flex items-center gap-1.5 mt-0.5">
<ListTodo className="h-3.5 w-3.5 text-muted-foreground" />
{taskCount}
</div>
</InfoRow>
<InfoRow label="Groups">
<div className="flex items-center gap-1.5 mt-0.5">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
{taskList.group_count ?? groups.length}
</div>
</InfoRow>
</div>
</SectionCard>
<SectionCard icon={Users} title="Assigned Groups">
{groups.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{groups.map((g) => (
@@ -217,95 +104,112 @@ export default function ViewTaskList() {
) : (
<p className="text-sm text-muted-foreground">No groups assigned.</p>
)}
</div>
</SectionCard>
{/* Tasks */}
<div className="space-y-4">
<div className="flex items-center gap-1.5 text-sm font-medium">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
Tasks
{tasks.length > 0 && (
<span className="ml-auto text-xs text-muted-foreground font-normal">
{tasks.length} task{tasks.length !== 1 ? 's' : ''}
</span>
)}
<SectionCard icon={CalendarClock} title="Audit">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Created At">
{taskList.createdAt ? fmtDateTime(taskList.createdAt) : '—'}
</InfoRow>
<InfoRow label="Updated At">
{taskList.updatedAt ? fmtDateTime(taskList.updatedAt) : '—'}
</InfoRow>
</div>
</SectionCard>
</div>
);
}
{tasks.length > 0 ? (
<Accordion
type="multiple"
className="rounded-md border divide-y"
defaultValue={[String(tasks[0]?.task_id ?? 0)]}
// ─── Tabs config ────────────────────────────────────────────────────────────
const TABS = [
{ key: 'details', label: 'Details', icon: Info },
{ key: 'tasks', label: 'Tasks', icon: NotebookPen },
];
// ─── Page ───────────────────────────────────────────────────────────────────
export default function ViewTaskList() {
const navigate = useNavigate();
const { taskListId } = useParams();
const [searchParams] = useSearchParams();
const { fetchTaskList } = useAdminTask();
const [taskList, setTaskList] = useState(null);
const [activeTab, setActiveTab] = useState(
searchParams.get('tab') === 'tasks' ? 'tasks' : 'details'
);
useEffect(() => {
fetchTaskList(taskListId).then((data) => {
if (!data) return;
setTaskList(data);
});
}, [taskListId]);
return (
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title={taskList ? `${taskList.name} - STARR` : undefined} description={taskList?.description} />
{/* ── Sticky 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)' }}
>
{tasks.map((task, index) => (
<AccordionItem
key={task.task_id ?? index}
value={String(task.task_id ?? index)}
className="px-3 border-0 border-b last:border-b-0"
<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/taskList')}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<ListTodo className="h-5 w-5 text-muted-foreground" />
View Task List
</h1>
{taskList && (
<p className="text-sm text-muted-foreground">{taskList.name}</p>
)}
</div>
{activeTab === 'details' && (
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/taskList/${taskListId}/edit`)}
disabled={!taskList}
>
<AccordionTrigger className="py-2.5 hover:no-underline gap-3 [&>svg]:shrink-0">
<div className="flex items-center gap-3 flex-1 min-w-0">
<span className="text-xs text-muted-foreground w-5 text-right shrink-0">
{index + 1}.
</span>
<span className="flex-1 text-sm text-left truncate w-48">
{task.name ?? `Task ${index + 1}`}
</span>
{task.requirements?.length > 0 && (
<Badge variant="secondary" className="text-xs shrink-0">
{task.requirements.length} Requirement{task.requirements.length !== 1 ? 's' : ''}
</Badge>
<Pencil className="h-4 w-4 mr-2" />
Edit
</Button>
)}
</div>
</AccordionTrigger>
<AccordionContent className="pb-4 pt-0">
<div className="ml-8 space-y-4 text-sm">
{task.description && (
<div className="flex items-center gap-2 text-muted-foreground">
<FileText className="size-4 shrink-0" />
<p className="leading-relaxed">{task.description}</p>
</div>
)}
{task.deadline && (
<div className="flex items-center gap-2 text-muted-foreground">
<CalendarClock className="size-4 shrink-0" />
<span className="text-sm">
Deadline:{' '}
<span className="text-foreground font-medium">
{fmtDateTime(task.deadline)}
</span>
</span>
</div>
)}
{task.prerequisites?.length > 0 && (
<div className="flex items-center gap-2 text-muted-foreground">
<GitBranch className="size-4 shrink-0" />
<span className="text-sm">
Requires:{' '}
<span className="text-foreground font-medium">
{task.prerequisites.map((p) => p.name).join(', ')}
</span>
</span>
</div>
)}
<TaskRequirementsSection requirements={task.requirements} />
</div>
</AccordionContent>
</AccordionItem>
{/* Underline tabs */}
<div className="flex gap-1 pb-0 -mb-px">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
type="button"
onClick={() => setActiveTab(key)}
className={[
'flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors',
activeTab === key
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
].join(' ')}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</Accordion>
) : (
<p className="text-sm text-muted-foreground">No tasks in this list yet.</p>
)}
</div>
</div>
</div>
</div>
{/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<div className={activeTab === 'tasks' ? 'pb-16' : 'max-w-3xl mx-auto pb-16'}>
{activeTab === 'details' && <DetailsTab taskList={taskList} />}
{activeTab === 'tasks' && <TasksTable taskListId={taskListId} />}
</div>
</div>
</div>
@@ -8,7 +8,7 @@ export default function ArchivedTask() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Task List", to: "/admin/taskList" },
{ label: "Tasks", to: `/admin/taskList/${taskListId}/tasks` },
{ label: "Tasks", to: `/admin/taskList/${taskListId}/view?tab=tasks` },
{ label: "Archived" },
];
@@ -89,7 +89,7 @@ export default function CreateTask() {
};
const handleBack = () => {
if (step === 0) navigate(`/admin/taskList/${taskListId}/tasks`);
if (step === 0) navigate(`/admin/taskList/${taskListId}/view?tab=tasks`);
else setStep((s) => s - 1);
};
@@ -117,7 +117,7 @@ export default function CreateTask() {
prerequisite_task_ids: form.prerequisite_task_ids,
});
if (created) navigate(`/admin/taskList/${taskListId}/tasks`);
if (created) navigate(`/admin/taskList/${taskListId}/view?tab=tasks`);
};
const formattedDeadline = (() => {
@@ -132,7 +132,7 @@ export default function CreateTask() {
{/* Header */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/taskList/${taskListId}/tasks`)}>
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/taskList/${taskListId}/view?tab=tasks`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-xl font-semibold">Create Task</h1>
@@ -103,7 +103,7 @@ export default function EditTask() {
requirements,
prerequisite_task_ids: form.prerequisite_task_ids ?? [],
});
if (updated) navigate(`/admin/taskList/${taskListId}/tasks`);
if (updated) navigate(`/admin/taskList/${taskListId}/view?tab=tasks`);
};
const handleSubmit = (e) => {
@@ -328,7 +328,7 @@ export default function ViewTask() {
<div className="flex gap-2 items-center">
<Button
variant="ghost"
onClick={() => navigate(`/admin/taskList/${taskListId}/tasks`)}
onClick={() => navigate(`/admin/taskList/${taskListId}/view?tab=tasks`)}
>
<ArrowLeft className="size-4" />
</Button>
@@ -100,7 +100,7 @@ export default function ViewTaskCompletion() {
const breadcrumbs = [
{ label: 'Home', icon: <House className="size-4" />, to: '/admin' },
{ label: 'Task Lists', to: '/admin/taskList' },
{ label: taskList?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks` },
{ label: taskList?.name ?? '…', to: `/admin/taskList/${taskListId}/view?tab=tasks` },
{ label: task?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view` },
{ label: 'Completions', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view?tab=completions` },
{ label: 'View' },
+2 -3
View File
@@ -1,4 +1,4 @@
import { Outlet } from 'react-router-dom'
import { Outlet, Navigate } from 'react-router-dom'
import ProtectedRoute from '../../../routes/ProtectedRoute'
// Layouts
@@ -75,7 +75,6 @@ import ArchivedLessonLibraryList from '../pages/library/lessons/ArchivedLessonLi
import TaskList from '../pages/task_list/TaskList'
import CreateTaskList from '../pages/task_list/CreateTaskList'
import EditTaskList from '../pages/task_list/EditTaskList'
import Tasks from '../pages/task_list/task/Tasks'
import ArchiveTaskList from '../pages/task_list/ArchiveTaskList'
import ViewTaskList from '../pages/task_list/ViewTaskList'
@@ -284,7 +283,7 @@ export const AdminRoutes = {
path: ':taskListId/tasks',
element: <Outlet />,
children: [
{ index: true, element: <Tasks /> },
{ index: true, element: <Navigate to="../view?tab=tasks" replace /> },
{ path: 'create', element: <CreateTask /> },
{ path: 'archived', element: <ArchivedTask /> },
{ path: ':taskId/view', element: <ViewTask /> },