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