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> <DialogTitle>New Lesson</DialogTitle>
</DialogHeader> </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"> <div className="space-y-1.5">
<Label htmlFor="lesson_title"> <Label htmlFor="lesson_title">
Title <span className="text-destructive">*</span> Title <span className="text-destructive">*</span>
@@ -93,7 +93,16 @@ export default function CreateUnitDialog({ open, onOpenChange, courseId, nextOrd
</DialogDescription> </DialogDescription>
</DialogHeader> </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 && ( {step === 0 && (
<> <>
<div className="space-y-1.5"> <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"> <div className="flex items-center gap-2 pt-1">
<Button <Button
type="button" variant="outline" size="sm" className="h-7 text-xs" 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>
<Button <Button
type="button" variant="outline" size="sm" className="h-7 text-xs" 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> </Button>
</div> </div>
</div> </div>
@@ -1,10 +1,17 @@
// AttachLessonsDialog — pick existing library Lessons and attach them to a Unit. // AttachLessonsDialog — pick existing library Lessons and attach them to a Unit.
// Junction revamp: attaching creates unit_lessons rows; the Lessons stay standalone. // 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 { useEffect, useMemo, useState } from "react";
import { Search, Link2 } from "lucide-react"; import { Search, Link2, Eye } from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext"; import { useLibrary } from "@/contexts/AdminLibraryContext";
import api from "@/utils/api.util";
import { import {
Dialog, DialogContent, DialogDescription, DialogFooter, Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle, DialogHeader, DialogTitle,
@@ -16,20 +23,49 @@ import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { formatDuration } from "@/utils/timestamp.util"; import { formatDuration } from "@/utils/timestamp.util";
import { PreviewChrome, PreviewContent } from "../courses/LessonsPreview";
export default function AttachLessonsDialog({ open, onOpenChange, attachedLessonIds = [], onAttach, loading }) { export default function AttachLessonsDialog({ open, onOpenChange, attachedLessonIds = [], onAttach, loading }) {
const { lessonsFlat, fetchLessonsFlat, loading: libraryLoading } = useLibrary(); const { lessonsFlat, fetchLessonsFlat, loading: libraryLoading } = useLibrary();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const [previewLessonId, setPreviewLessonId] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewCache, setPreviewCache] = useState({});
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setSelected([]); setSelected([]);
setQuery(""); setQuery("");
setPreviewLessonId(null);
setPreviewCache({});
fetchLessonsFlat(); fetchLessonsFlat();
} }
}, [open, 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( const attachedSet = useMemo(
() => new Set(attachedLessonIds.map(String)), () => new Set(attachedLessonIds.map(String)),
[attachedLessonIds] [attachedLessonIds]
@@ -55,64 +91,105 @@ export default function AttachLessonsDialog({ open, onOpenChange, attachedLesson
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Link2 className="h-4 w-4" /> Select Lessons <Link2 className="h-4 w-4" /> Select Lessons
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Lessons live independently in the library — attaching adds them to this unit without copying. Lessons live independently in the library — attaching adds them to this unit without copying.
Click a lesson to preview its content.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="relative"> <div className="grid gap-4 sm:grid-cols-[280px_1fr]">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <div className="space-y-2 min-w-0">
<Input <div className="relative">
placeholder="Search lessons..." <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
className="pl-8" <Input
value={query} placeholder="Search lessons..."
onChange={(e) => setQuery(e.target.value)} className="pl-8"
/> value={query}
</div> onChange={(e) => setQuery(e.target.value)}
/>
</div>
<ScrollArea className="h-64 rounded-md border"> <ScrollArea className="h-80 rounded-md border">
{libraryLoading ? ( {libraryLoading ? (
<div className="flex items-center justify-center h-full py-10"> <div className="flex items-center justify-center h-full py-10">
<Spinner className="h-5 w-5" /> <Spinner className="h-5 w-5" />
</div> </div>
) : candidates.length === 0 ? ( ) : 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."} {query ? "No lessons match your search." : "Every library lesson is already attached."}
</p> </p>
) : ( ) : (
<div className="divide-y"> <div className="divide-y">
{candidates.map((l) => ( {candidates.map((l) => (
<label <div
key={l.lesson_id} 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);
<Checkbox setPreviewLessonId(l.lesson_id);
checked={selected.includes(l.lesson_id)} }}
onCheckedChange={() => toggle(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" : ""
<div className="flex-1 min-w-0"> }`}
<p className="text-sm font-medium w-64 truncate">{l.title}</p> >
<p className="text-xs text-muted-foreground truncate"> <Checkbox
{formatDuration(l.duration_seconds ?? 0)} checked={selected.includes(l.lesson_id)}
</p> 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 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">
{l.unit_count}
</Badge>
) : (
<Badge variant="outline" className="text-xs shrink-0 text-muted-foreground">new</Badge>
)}
</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> </div>
{Number(l.unit_count) > 0 ? ( ) : previewLoading ? (
<Badge variant="secondary" className="text-xs shrink-0"> <div className="flex items-center justify-center h-64">
in {l.unit_count} unit{Number(l.unit_count) === 1 ? "" : "s"} <Spinner className="h-5 w-5" />
</Badge> </div>
) : ( ) : (
<Badge variant="outline" className="text-xs shrink-0 text-muted-foreground">standalone</Badge> <PreviewChrome title={previewLesson?.title}>
)} <PreviewContent
</label> lesson={previewLesson}
))} blocks={previewBlocks}
</div> showHeader={false}
)} empty="No content blocks yet."
</ScrollArea> />
</PreviewChrome>
)}
</div>
</ScrollArea>
</div>
</div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}> <Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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 { useAdminTask } from '@/contexts/AdminTaskContext';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
@@ -8,24 +8,13 @@ import DataTable from '@/components/generic/Table/DataTable';
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { ArchiveDialog } from '@/components/generic/Dialogs/ArchiveDialog'; import { ArchiveDialog } from '@/components/generic/Dialogs/ArchiveDialog';
import { RestoreDialog } from '@/components/generic/Dialogs/RestoreDialog'; import { RestoreDialog } from '@/components/generic/Dialogs/RestoreDialog';
import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner';
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 { import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription, AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Spinner } from '@/components/ui/spinner'; import { TriangleAlert } from 'lucide-react';
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
import { formatDate } from '@/utils/table.util';
import { buildDataColumns, columnPinning } from '@/modules/admin/config/task_list/task/columns.config'; import { buildDataColumns, columnPinning } from '@/modules/admin/config/task_list/task/columns.config';
import { buildToolbarActions } from '@/modules/admin/config/task_list/task/toolbar.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 { getTimestamp } from '@/utils/timestamp.util';
import { formatGeneratedBy } from '@/utils/generatedBy.util'; import { formatGeneratedBy } from '@/utils/generatedBy.util';
export default function Tasks() { export default function TasksTable({ taskListId }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { taskListId } = useParams();
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const { const {
taskList, tasks, attributes, pagination, loading, tasks, attributes, pagination, loading,
fetchTaskList, fetchTasks, fetchArchivedTasks, fetchTasks, fetchArchivedTasks,
archiveTask, restoreTask, fetchTaskFieldValues, archiveTask, restoreTask, fetchTaskFieldValues,
bulkArchiveTasks, bulkRestoreTasks, reorderTasks, bulkArchiveTasks, bulkRestoreTasks, reorderTasks,
} = useAdminTask(); } = useAdminTask();
@@ -52,14 +40,12 @@ export default function Tasks() {
const [restoreTarget, setRestoreTarget] = useState(null); const [restoreTarget, setRestoreTarget] = useState(null);
const [bulkArchiveIds, setBulkArchiveIds] = useState(null); const [bulkArchiveIds, setBulkArchiveIds] = useState(null);
const [bulkRestoreIds, setBulkRestoreIds] = useState(null); const [bulkRestoreIds, setBulkRestoreIds] = useState(null);
const [showGroupsDialog, setShowGroupsDialog] = useState(false);
const tableRefsRef = useRef({ const tableRefsRef = useRef({
getFilters: () => [], getSort: () => [], resetSelection: () => { }, tableInstance: null, getFilters: () => [], getSort: () => [], resetSelection: () => { }, tableInstance: null,
}); });
useEffect(() => { useEffect(() => {
fetchTaskList(taskListId);
fetchTasks(taskListId, { page: 1, limit: 10 }); fetchTasks(taskListId, { page: 1, limit: 10 });
}, [taskListId]); }, [taskListId]);
@@ -126,7 +112,7 @@ export default function Tasks() {
}); });
const toolbarActions = buildToolbarActions({ const toolbarActions = buildToolbarActions({
fetchTasks, fetchArchivedTasks, taskListId, fetchTasks, fetchArchivedTasks, taskListId,
pagination, navigate, exportConfig, pagination, navigate, exportConfig,
showArchived, onToggleArchived: handleToggleArchived, showArchived, onToggleArchived: handleToggleArchived,
getFilters: () => tableRefsRef.current.getFilters(), getFilters: () => tableRefsRef.current.getFilters(),
@@ -134,7 +120,6 @@ export default function Tasks() {
getTableInstance: () => tableRefsRef.current.tableInstance, getTableInstance: () => tableRefsRef.current.tableInstance,
}); });
// ── Selection ─────────────────────────────────────────────────────────────
const selectionActions = buildSelectionActions({ const selectionActions = buildSelectionActions({
showArchived, showArchived,
onBulkArchive: (ids) => setBulkArchiveIds(ids), onBulkArchive: (ids) => setBulkArchiveIds(ids),
@@ -144,110 +129,8 @@ export default function Tasks() {
const columns = useMemo(() => buildDataColumns(attributes, rowActions), [attributes, rowActions]); 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 ( 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 <DataTable
columns={columns} columns={columns}
data={tasks} data={tasks}
@@ -259,6 +142,8 @@ export default function Tasks() {
selectionActions={selectionActions} selectionActions={selectionActions}
columnPinning={columnPinning} columnPinning={columnPinning}
onRefsReady={handleRefsReady} onRefsReady={handleRefsReady}
recordLabel="task"
emptyMessage="No tasks in this list yet."
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet <FilterSheet
open={open} 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 ────────────────────────────────────────────── */} {/* ── Single archive ────────────────────────────────────────────── */}
<AlertDialog open={!!archiveTarget} onOpenChange={(v) => !v && setArchiveTarget(null)}> <AlertDialog open={!!archiveTarget} onOpenChange={(v) => !v && setArchiveTarget(null)}>
<AlertDialogContent className="sm:max-w-sm"> <AlertDialogContent className="sm:max-w-sm">
@@ -366,7 +228,6 @@ export default function Tasks() {
loading={loading} loading={loading}
onSuccess={afterMutation} 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 }) { export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
return [ return [
@@ -8,14 +8,6 @@ export function buildRowActions({ navigate, onArchive, onRestore, showArchived }
icon: <Eye className="size-4" />, icon: <Eye className="size-4" />,
onClick: (row) => navigate(`${row.task_list_id}/view`), 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", key: "archive",
label: "Archive", label: "Archive",
@@ -7,7 +7,7 @@ import { nanoid } from "nanoid";
import { import {
ArrowLeft, ChevronLeft, ChevronRight, Check, ArrowLeft, ChevronLeft, ChevronRight, Check,
FileText, BookOpen, LayoutTemplate, ClipboardCheck, ListChecks, FileText, BookOpen, LayoutTemplate, ClipboardCheck, ListChecks,
Plus, Trash2, Link2, Plus, Trash2, Link2, Eye,
} from "lucide-react"; } from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext"; import { useLibrary } from "@/contexts/AdminLibraryContext";
@@ -250,12 +250,13 @@ function StepLessons({ control, register, errors, existingLessons, onRemoveExist
} }
// ─── Step 3 — Page Builder ───────────────────────────────────────────────────── // ─── Step 3 — Page Builder ─────────────────────────────────────────────────────
function StepPageBuilder({ control, setValue }) { function StepPageBuilder({ control, setValue, existingLessons }) {
const lessons = useWatch({ control, name: "lessons" }) ?? []; const lessons = useWatch({ control, name: "lessons" }) ?? [];
const [rawIndex, setActiveIndex] = useState(0); const [rawIndex, setActiveIndex] = useState(0);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [viewingExisting, setViewingExisting] = useState(null);
if (lessons.length === 0) { if (lessons.length === 0 && existingLessons.length === 0) {
return ( return (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Add at least one lesson in the previous step to build its page content here. Add at least one lesson in the previous step to build its page content here.
@@ -288,6 +289,29 @@ function StepPageBuilder({ control, setValue }) {
return ( return (
<div className="space-y-3"> <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) => ( {lessons.map((l, i) => (
<div key={i} className="flex items-center justify-between border border-border rounded-lg p-4"> <div key={i} className="flex items-center justify-between border border-border rounded-lg p-4">
<div> <div>
@@ -345,13 +369,13 @@ function StepPageBuilder({ control, setValue }) {
<div className="space-y-3"> <div className="space-y-3">
<div className="text-sm font-medium text-muted-foreground">Live Preview</div> <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]"> <div className="p-6 space-y-5 min-h-[300px]">
<PreviewContent <PreviewContent
lesson={{ lesson={{
title: activeLesson.title, title: activeLesson?.title,
description: activeLesson.description, description: activeLesson?.description,
objectives: (activeLesson.objectives ?? []).map((o, oi) => ({ objective_id: oi, text: o.value })), objectives: (activeLesson?.objectives ?? []).map((o, oi) => ({ objective_id: oi, text: o.value })),
}} }}
blocks={blocks} blocks={blocks}
empty="Your content will appear here as you build." empty="Your content will appear here as you build."
@@ -369,6 +393,37 @@ function StepPageBuilder({ control, setValue }) {
</DrawerFooter> </DrawerFooter>
</DrawerContent> </DrawerContent>
</Drawer> </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> </div>
); );
} }
@@ -612,7 +667,7 @@ export default function AddLibraryUnit() {
/> />
)} )}
{step === 2 && ( {step === 2 && (
<StepPageBuilder control={control} setValue={setValue} /> <StepPageBuilder control={control} setValue={setValue} existingLessons={existingLessons} />
)} )}
{step === 3 && ( {step === 3 && (
<StepRequirements requirements={requirements} setRequirements={setRequirements} /> <StepRequirements requirements={requirements} setRequirements={setRequirements} />
@@ -657,12 +712,33 @@ export default function AddLibraryUnit() {
open={attachLessonsOpen} open={attachLessonsOpen}
onOpenChange={setAttachLessonsOpen} onOpenChange={setAttachLessonsOpen}
attachedLessonIds={existingLessons.map((l) => l.lesson_id)} attachedLessonIds={existingLessons.map((l) => l.lesson_id)}
onAttach={(lessonIds) => { onAttach={async (lessonIds) => {
const picked = lessonIds const picked = lessonIds
.map((id) => lessonsFlat.find((l) => l.lesson_id === id)) .map((id) => lessonsFlat.find((l) => l.lesson_id === id))
.filter(Boolean) .filter(Boolean);
.map((l) => ({ lesson_id: l.lesson_id, title: l.title }));
setExistingLessons((prev) => [...prev, ...picked]); // 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]);
}} }}
/> />
+163 -259
View File
@@ -1,145 +1,144 @@
import { useEffect, useState } from 'react'; 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 { useAdminTask } from '@/contexts/AdminTaskContext';
import { useDateFormat } from '@/hooks/useDateFormat'; import { useDateFormat } from '@/hooks/useDateFormat';
import { PageMeta } from '@/contexts/MetadataContext';
import TasksTable from '../../components/task_list/TasksTable';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import { import {
Accordion, ArrowLeft, Pencil, Users, ListTodo, Info,
AccordionContent, NotebookPen, FileText, CalendarClock,
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,
} from 'lucide-react'; } from 'lucide-react';
// ─── All styling uses shadcn tokens — only label/icon differs per type // ─── Helpers ────────────────────────────────────────────────────────────────
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 },
};
// ─── Label / value row — fully themed by shadcn tokens ─────────────────────── function InfoRow({ label, children }) {
function MetaRow({ icon: Icon, label, children }) {
return ( 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 flex-col gap-0.5">
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0"> <span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
<Icon className="size-3.5" /> <span className="text-sm font-medium">{children ?? <span className="text-muted-foreground italic">—</span>}</span>
{label}
</div>
<div className="text-xs text-foreground text-right min-w-0">
{children}
</div>
</div> </div>
); );
} }
// ─── Requirement card ───────────────────────────────────────────────────────── function SectionCard({ icon: Icon, title, children }) {
function RequirementCard({ req }) { return (
const cfg = REQUIREMENT_CONFIG[req.type] ?? { <div className="rounded-lg border bg-card p-5 space-y-4">
label: req.type, badgeLabel: req.type, Icon: FileText, accent: 'text-muted-foreground', <div className="flex items-center gap-2">
}; {Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
const { Icon } = cfg; <h2 className="text-sm font-semibold">{title}</h2>
</div>
<Separator />
{children}
</div>
);
}
function LoadingSkeleton() {
return (
<div className="space-y-5">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
}
// ─── Tab: Details ───────────────────────────────────────────────────────────
function DetailsTab({ taskList }) {
const { fmtDateTime } = useDateFormat();
if (!taskList) return <LoadingSkeleton />;
const groups = taskList.groups ?? [];
const taskCount = taskList.tasks?.length ?? 0;
return ( return (
<div className="border border-border rounded-xl bg-background"> <div className="space-y-5">
<SectionCard icon={FileText} title="Basic Information">
{/* Header */} <div className="space-y-4">
<div className="flex items-center gap-2.5 px-3.5 py-3 border-b border-border"> <InfoRow label="Name">{taskList.name}</InfoRow>
<div className="size-8 rounded-lg flex items-center justify-center shrink-0 bg-muted"> {taskList.description && (
<Icon className="size-4 text-foreground" /> <InfoRow label="Description">
<span className="whitespace-pre-wrap text-sm font-normal text-foreground">
{taskList.description}
</span>
</InfoRow>
)}
</div> </div>
<span className="text-sm font-medium flex-1 text-foreground"> </SectionCard>
{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 */} <SectionCard icon={ListTodo} title="Stats">
{req.type === 'visit_link' && ( <div className="grid grid-cols-2 gap-4">
<> <InfoRow label="Tasks">
{req.link_label && ( <div className="flex items-center gap-1.5 mt-0.5">
<MetaRow icon={Tag} label="Label"> <ListTodo className="h-3.5 w-3.5 text-muted-foreground" />
<span>{req.link_label}</span> {taskCount}
</MetaRow> </div>
)} </InfoRow>
{req.link_url && ( <InfoRow label="Groups">
<MetaRow icon={Globe} label="URL"> <div className="flex items-center gap-1.5 mt-0.5">
<a <Users className="h-3.5 w-3.5 text-muted-foreground" />
href={/^https?:\/\//i.test(req.link_url) ? req.link_url : `https://${req.link_url}`} {taskList.group_count ?? groups.length}
target="_blank" </div>
rel="noopener noreferrer" </InfoRow>
className="text-blue-500 underline underline-offset-2 truncate block max-w-[240px] hover:opacity-80 transition-opacity" </div>
> </SectionCard>
{req.link_url}
</a>
</MetaRow>
)}
</>
)}
{req.type === 'upload_file' && ( <SectionCard icon={Users} title="Assigned Groups">
<> {groups.length > 0 ? (
{req.max_file_count != null && ( <div className="flex flex-wrap gap-1.5">
<MetaRow icon={Copy} label="Max files"> {groups.map((g) => (
<span className="font-medium">{req.max_file_count}</span> <Badge key={g.group_id} variant="secondary">
</MetaRow> <Users className="size-3 mr-1" />
)} {g.name ?? g.group_id}
{req.allowed_file_types?.length > 0 && ( </Badge>
<MetaRow icon={File} label="Allowed types"> ))}
<span>{req.allowed_file_types.join(', ')}</span> </div>
</MetaRow> ) : (
)} <p className="text-sm text-muted-foreground">No groups assigned.</p>
</> )}
)} </SectionCard>
{['read_course', 'read_unit', 'read_lesson'].includes(req.type) && req.reference_label && ( <SectionCard icon={CalendarClock} title="Audit">
<MetaRow icon={Bookmark} label={cfg.badgeLabel}> <div className="grid grid-cols-2 gap-4">
<span className="truncate block max-w-[220px]">{req.reference_label}</span> <InfoRow label="Created At">
</MetaRow> {taskList.createdAt ? fmtDateTime(taskList.createdAt) : '—'}
)} </InfoRow>
<InfoRow label="Updated At">
{taskList.updatedAt ? fmtDateTime(taskList.updatedAt) : '—'}
</InfoRow>
</div>
</SectionCard>
</div> </div>
); );
} }
// ─── Requirements section ───────────────────────────────────────────────────── // ─── Tabs config ────────────────────────────────────────────────────────────
function TaskRequirementsSection({ requirements = [] }) {
const sorted = [...requirements].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); const TABS = [
return ( { key: 'details', label: 'Details', icon: Info },
<div className="space-y-2"> { key: 'tasks', label: 'Tasks', icon: NotebookPen },
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> ];
Requirements
</p> // ─── Page ───────────────────────────────────────────────────────────────────
{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>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ViewTaskList() { export default function ViewTaskList() {
const navigate = useNavigate(); const navigate = useNavigate();
const { taskListId } = useParams(); const { taskListId } = useParams();
const [searchParams] = useSearchParams();
const { fetchTaskList } = useAdminTask(); const { fetchTaskList } = useAdminTask();
const { fmtDateTime } = useDateFormat();
const [taskList, setTaskList] = useState(null); const [taskList, setTaskList] = useState(null);
const [activeTab, setActiveTab] = useState(
searchParams.get('tab') === 'tasks' ? 'tasks' : 'details'
);
useEffect(() => { useEffect(() => {
fetchTaskList(taskListId).then((data) => { fetchTaskList(taskListId).then((data) => {
@@ -148,166 +147,71 @@ export default function ViewTaskList() {
}); });
}, [taskListId]); }, [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>
);
const groups = taskList.groups ?? [];
const tasks = taskList.tasks ?? [];
return ( return (
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10"> <div className="flex flex-col min-h-screen bg-muted/60">
<div className="mx-auto space-y-4"> <PageMeta title={taskList ? `${taskList.name} - STARR` : undefined} description={taskList?.description} />
{/* 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" />
{/* ── 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)' }}
>
<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> </Button>
<div className="space-y-1"> <div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold">{taskList.name}</h1> <h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
{taskList.description && ( <ListTodo className="h-5 w-5 text-muted-foreground" />
<p className="text-sm text-muted-foreground leading-relaxed"> View Task List
{taskList.description} </h1>
</p> {taskList && (
<p className="text-sm text-muted-foreground">{taskList.name}</p>
)} )}
</div> </div>
{activeTab === 'details' && (
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/taskList/${taskListId}/edit`)}
disabled={!taskList}
>
<Pencil className="h-4 w-4 mr-2" />
Edit
</Button>
)}
</div>
{/* 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>
))}
</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> </div>
</div>
<div className="lg:w-2xl rounded-lg border border-border bg-card text-card-foreground shadow-sm"> {/* ── Content ── */}
<div className="p-6 space-y-6"> <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'}>
{/* Assigned Groups */} {activeTab === 'details' && <DetailsTab taskList={taskList} />}
<div className="space-y-4"> {activeTab === 'tasks' && <TasksTable taskListId={taskListId} />}
<div className="flex items-center gap-1.5 text-sm font-medium">
<Equal className="size-4" />
Assigned Groups
</div>
{groups.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{groups.map((g) => (
<Badge key={g.group_id} variant="secondary">
<Users className="size-3 mr-1" />
{g.name ?? g.group_id}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No groups assigned.</p>
)}
</div>
{/* 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>
)}
</div>
{tasks.length > 0 ? (
<Accordion
type="multiple"
className="rounded-md border divide-y"
defaultValue={[String(tasks[0]?.task_id ?? 0)]}
>
{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"
>
<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>
)}
</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>
))}
</Accordion>
) : (
<p className="text-sm text-muted-foreground">No tasks in this list yet.</p>
)}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
@@ -8,7 +8,7 @@ export default function ArchivedTask() {
const items = [ const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" }, { label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Task List", to: "/admin/taskList" }, { label: "Task List", to: "/admin/taskList" },
{ label: "Tasks", to: `/admin/taskList/${taskListId}/tasks` }, { label: "Tasks", to: `/admin/taskList/${taskListId}/view?tab=tasks` },
{ label: "Archived" }, { label: "Archived" },
]; ];
@@ -89,7 +89,7 @@ export default function CreateTask() {
}; };
const handleBack = () => { 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); else setStep((s) => s - 1);
}; };
@@ -117,7 +117,7 @@ export default function CreateTask() {
prerequisite_task_ids: form.prerequisite_task_ids, 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 = (() => { const formattedDeadline = (() => {
@@ -132,7 +132,7 @@ export default function CreateTask() {
{/* Header */} {/* Header */}
<div className="flex items-center gap-3"> <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" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<h1 className="text-xl font-semibold">Create Task</h1> <h1 className="text-xl font-semibold">Create Task</h1>
@@ -103,7 +103,7 @@ export default function EditTask() {
requirements, requirements,
prerequisite_task_ids: form.prerequisite_task_ids ?? [], 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) => { const handleSubmit = (e) => {
@@ -328,7 +328,7 @@ export default function ViewTask() {
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Button <Button
variant="ghost" variant="ghost"
onClick={() => navigate(`/admin/taskList/${taskListId}/tasks`)} onClick={() => navigate(`/admin/taskList/${taskListId}/view?tab=tasks`)}
> >
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
</Button> </Button>
@@ -100,7 +100,7 @@ export default function ViewTaskCompletion() {
const breadcrumbs = [ const breadcrumbs = [
{ label: 'Home', icon: <House className="size-4" />, to: '/admin' }, { label: 'Home', icon: <House className="size-4" />, to: '/admin' },
{ label: 'Task Lists', to: '/admin/taskList' }, { 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: task?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view` },
{ label: 'Completions', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view?tab=completions` }, { label: 'Completions', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view?tab=completions` },
{ label: 'View' }, { 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' import ProtectedRoute from '../../../routes/ProtectedRoute'
// Layouts // Layouts
@@ -75,7 +75,6 @@ import ArchivedLessonLibraryList from '../pages/library/lessons/ArchivedLessonLi
import TaskList from '../pages/task_list/TaskList' import TaskList from '../pages/task_list/TaskList'
import CreateTaskList from '../pages/task_list/CreateTaskList' import CreateTaskList from '../pages/task_list/CreateTaskList'
import EditTaskList from '../pages/task_list/EditTaskList' import EditTaskList from '../pages/task_list/EditTaskList'
import Tasks from '../pages/task_list/task/Tasks'
import ArchiveTaskList from '../pages/task_list/ArchiveTaskList' import ArchiveTaskList from '../pages/task_list/ArchiveTaskList'
import ViewTaskList from '../pages/task_list/ViewTaskList' import ViewTaskList from '../pages/task_list/ViewTaskList'
@@ -284,7 +283,7 @@ export const AdminRoutes = {
path: ':taskListId/tasks', path: ':taskListId/tasks',
element: <Outlet />, element: <Outlet />,
children: [ children: [
{ index: true, element: <Tasks /> }, { index: true, element: <Navigate to="../view?tab=tasks" replace /> },
{ path: 'create', element: <CreateTask /> }, { path: 'create', element: <CreateTask /> },
{ path: 'archived', element: <ArchivedTask /> }, { path: 'archived', element: <ArchivedTask /> },
{ path: ':taskId/view', element: <ViewTask /> }, { path: ':taskId/view', element: <ViewTask /> },