mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
added more things
This commit is contained in:
@@ -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}>
|
||||||
|
|||||||
+12
-151
@@ -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]);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
|
|||||||
Reference in New Issue
Block a user