diff --git a/src/contexts/AdminCoursesContext.jsx b/src/contexts/AdminCoursesContext.jsx
index 0db6c4e..583c257 100644
--- a/src/contexts/AdminCoursesContext.jsx
+++ b/src/contexts/AdminCoursesContext.jsx
@@ -167,6 +167,15 @@ export function CoursesProvider({ children }) {
[request],
);
+ const reorderCourses = useCallback(
+ (courseIds) =>
+ request(async () => {
+ await api.put(`${BASE}/order`, { course_ids: courseIds });
+ return true;
+ }),
+ [request],
+ );
+
const archiveCourse = useCallback(
(courseId) =>
request(async () => {
@@ -1284,6 +1293,7 @@ export function CoursesProvider({ children }) {
createCourse,
createCourseFull,
updateCourse,
+ reorderCourses,
archiveCourse,
archiveCourses,
diff --git a/src/modules/admin/components/courses/CourseTable.jsx b/src/modules/admin/components/courses/CourseTable.jsx
index d157e05..be74133 100644
--- a/src/modules/admin/components/courses/CourseTable.jsx
+++ b/src/modules/admin/components/courses/CourseTable.jsx
@@ -29,7 +29,7 @@ export default function CoursesTable() {
const navigate = useNavigate();
- const { courses, attributes, pagination, setPagination, loading, fetchCourses, archiveCourse, archiveCourses, fetchCourseFieldValues } = useCourses();
+ const { courses, attributes, pagination, setPagination, loading, fetchCourses, reorderCourses, archiveCourse, archiveCourses, fetchCourseFieldValues } = useCourses();
const handleRefsReady = (refs) => {
tableRefsRef.current = refs;
@@ -42,14 +42,31 @@ export default function CoursesTable() {
sheetName: "Courses",
};
- const rowActions = useMemo(() => buildRowActions({
+ // Reorder — swaps this row with its neighbor in the currently displayed
+ // (order_index-sorted) list, then persists the new order_index set.
+ const handleMove = async (row, direction) => {
+ const idx = courses.findIndex((c) => c.course_id === row.course_id);
+ const swapIdx = idx + direction;
+ if (idx < 0 || swapIdx < 0 || swapIdx >= courses.length) return;
+
+ const reordered = [...courses];
+ [reordered[idx], reordered[swapIdx]] = [reordered[swapIdx], reordered[idx]];
+
+ const ok = await reorderCourses(reordered.map((c) => c.course_id));
+ if (ok) fetchCourses({ page: 1, limit: pagination?.limit ?? 10 });
+ };
+
+ const rowActions = buildRowActions({
onViewAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment/view`),
onAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment`),
onViewUnits: (row) => navigate(`/admin/courses/${row.course_id}/units`),
onView: (row) => navigate(`/admin/courses/${row.course_id}/view`),
onEdit: (row) => navigate(`/admin/courses/${row.course_id}/edit`),
onArchive: (row) => setArchiveTarget(row),
- }), []);
+ onMoveUp: (row) => handleMove(row, -1),
+ onMoveDown: (row) => handleMove(row, 1),
+ courses,
+ });
const toolbarActions = buildToolbarActions({
fetchCourses,
@@ -70,7 +87,7 @@ export default function CoursesTable() {
const columns = useMemo(
() => buildDataColumns(attributes, rowActions),
- [attributes]
+ [attributes, rowActions]
);
const handleArchiveSuccess = () => {
diff --git a/src/modules/admin/config/courses/rowActions.config.jsx b/src/modules/admin/config/courses/rowActions.config.jsx
index c8bd542..099ac76 100644
--- a/src/modules/admin/config/courses/rowActions.config.jsx
+++ b/src/modules/admin/config/courses/rowActions.config.jsx
@@ -1,6 +1,6 @@
-import { Eye, Archive, ShelvingUnit, NotebookPen, ClipboardList, PlusCircle } from "lucide-react";
+import { Eye, Archive, ShelvingUnit, NotebookPen, ClipboardList, PlusCircle, ArrowUp, ArrowDown } from "lucide-react";
-export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAssessment, onViewAssessment }) {
+export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAssessment, onViewAssessment, onMoveUp, onMoveDown, courses = [] }) {
return [
{
key: "view",
@@ -8,6 +8,21 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
icon: ,
onClick: (row) => onView(row),
},
+ {
+ key: "move-up",
+ label: "Move Up",
+ icon: ,
+ onClick: (row) => onMoveUp(row),
+ separator: true,
+ disabled: (row) => courses.findIndex((c) => c.course_id === row.course_id) <= 0,
+ },
+ {
+ key: "move-down",
+ label: "Move Down",
+ icon: ,
+ onClick: (row) => onMoveDown(row),
+ disabled: (row) => courses.findIndex((c) => c.course_id === row.course_id) >= courses.length - 1,
+ },
{
key: "view_units",
label: "View Units",
diff --git a/src/modules/admin/config/task_list/task/rowActions.config.jsx b/src/modules/admin/config/task_list/task/rowActions.config.jsx
index 45c7da1..989e25f 100644
--- a/src/modules/admin/config/task_list/task/rowActions.config.jsx
+++ b/src/modules/admin/config/task_list/task/rowActions.config.jsx
@@ -8,14 +8,6 @@ export function buildRowActions({ navigate, onArchive, onRestore, onMoveUp, onMo
icon: ,
onClick: (row) => navigate(`${row.task_id}/view`),
},
- {
- key: "completions",
- label: "Completions",
- icon: ,
- onClick: (row) => navigate(`${row.task_id}/completions`),
- separator: true,
- className: "text-sky-600",
- },
{
key: "move-up",
label: "Move Up",
diff --git a/src/modules/admin/config/task_list/task_completion/rowActions.config.jsx b/src/modules/admin/config/task_list/task_completion/rowActions.config.jsx
index aead182..2f5f0fd 100644
--- a/src/modules/admin/config/task_list/task_completion/rowActions.config.jsx
+++ b/src/modules/admin/config/task_list/task_completion/rowActions.config.jsx
@@ -1,13 +1,13 @@
// config/task_completion/rowActions.config.jsx
import { Eye, Archive, RotateCcw, ShieldCheck } from "lucide-react";
-export function buildRowActions({ navigate, onArchive, onRestore, onReview, showArchived }) {
+export function buildRowActions({ navigate, taskListId, taskId, onArchive, onRestore, onReview, showArchived }) {
return [
{
key: "view",
label: "View",
icon: ,
- onClick: (row) => navigate(`${row.completion_id}/view`),
+ onClick: (row) => navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/completions/${row.completion_id}/view`),
},
{
key: "review",
diff --git a/src/modules/admin/pages/courses/AddCourse.jsx b/src/modules/admin/pages/courses/AddCourse.jsx
index 148d405..cd8faa1 100644
--- a/src/modules/admin/pages/courses/AddCourse.jsx
+++ b/src/modules/admin/pages/courses/AddCourse.jsx
@@ -35,7 +35,6 @@ const schema = z.object({
title: z.string().min(1, "Title is required."),
description: z.string().optional(),
course_code: z.string().min(1, "Course Code is required."),
- order_index: z.coerce.number().min(0).default(0),
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
subscription: z.string().min(1, "Subscription is required.").default("free"),
status: z.enum(["draft", "published", "unpublished"]).default("draft"),
@@ -173,7 +172,6 @@ export default function AddCourse() {
title: "",
description: "",
course_code: "",
- order_index: 0,
level: "beginner",
subscription: "free",
status: "draft",
@@ -192,7 +190,6 @@ export default function AddCourse() {
const watchedTitle = useWatch({ control, name: "title" });
const watchedDescription = useWatch({ control, name: "description" });
const watchedCourseCode = useWatch({ control, name: "course_code" });
- const watchedOrderIndex = useWatch({ control, name: "order_index" });
const watchedLevel = useWatch({ control, name: "level" });
const watchedSubscr = useWatch({ control, name: "subscription" });
const watchedStatus = useWatch({ control, name: "status" });
@@ -252,7 +249,6 @@ export default function AddCourse() {
title: values.title,
description: values.description,
course_code: values.course_code || null,
- order_index: values.order_index,
level: values.level || null,
subscription: values.subscription,
status: values.status,
@@ -338,19 +334,12 @@ export default function AddCourse() {
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
@@ -640,10 +629,6 @@ export default function AddCourse() {
Level
{watchedLevel || "—"}
-
-
Order
-
{watchedOrderIndex ?? 0}
-
Subscription
{watchedSubscr || "—"}
diff --git a/src/modules/admin/pages/courses/EditCourse.jsx b/src/modules/admin/pages/courses/EditCourse.jsx
index a5a6e31..15f2c7d 100644
--- a/src/modules/admin/pages/courses/EditCourse.jsx
+++ b/src/modules/admin/pages/courses/EditCourse.jsx
@@ -46,7 +46,6 @@ const schema = z.object({
title: z.string().min(1, "Title is required."),
description: z.string().optional(),
course_code: z.string().optional(),
- order_index: z.coerce.number().min(0).default(0),
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
subscription: z.string().min(1, "Subscription is required.").default("free"),
status: z.enum(["draft", "published", "unpublished"]).default("draft"),
@@ -236,7 +235,7 @@ export default function EditCourse() {
resolver: zodResolver(schema),
defaultValues: {
title: "", description: "", course_code: "",
- order_index: 0, level: undefined, subscription: "free", status: "draft", objectives: [], roles: [],
+ level: undefined, subscription: "free", status: "draft", objectives: [], roles: [],
},
});
@@ -276,7 +275,6 @@ export default function EditCourse() {
title: c.title ?? "",
description: c.description ?? "",
course_code: c.course_code ?? "",
- order_index: c.order_index ?? 0,
level: c.level ?? undefined,
subscription: c.subscription ?? "free",
status: c.status ?? "draft",
@@ -595,26 +593,14 @@ export default function EditCourse() {
/>
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
diff --git a/src/modules/admin/pages/task_list/task/TaskCompletion.jsx b/src/modules/admin/pages/task_list/task/TaskCompletion.jsx
deleted file mode 100644
index 9d27207..0000000
--- a/src/modules/admin/pages/task_list/task/TaskCompletion.jsx
+++ /dev/null
@@ -1,349 +0,0 @@
-/***********************************************************************************************************************************************************************
- * File Name : TaskCompletions.jsx
- * Type : Page
- * Description : Admin page — lists all completions for a specific task.
- * Route: /admin/taskList/:taskListId/tasks/:taskId/completions
- ***********************************************************************************************************************************************************************/
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { useNavigate, useParams } from 'react-router-dom';
-import { useAdminTask } from '@/contexts/AdminTaskContext';
-
-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 { Skeleton } from '@/components/ui/skeleton';
-import { Button } from '@/components/ui/button';
-import { Textarea } from '@/components/ui/textarea';
-import { Switch } from '@/components/ui/switch';
-import { Label } from '@/components/ui/label';
-import {
- AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
- AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
-} from '@/components/ui/alert-dialog';
-import { House, NotebookPen, Users, Paperclip, Check, X } from 'lucide-react';
-import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
-import { formatDate, formatDateTime } from '@/utils/table.util';
-import { getTimestamp } from '@/utils/timestamp.util';
-
-import { buildDataColumns, columnPinning } from '@/modules/admin/config/task_list/task_completion/columns.config';
-import { buildToolbarActions } from '@/modules/admin/config/task_list/task_completion/toolbar.config';
-import { buildSelectionActions } from '@/modules/admin/config/task_list/task_completion/selection.config';
-import { buildRowActions } from '@/modules/admin/config/task_list/task_completion/rowActions.config';
-
-// ─── Stat card ────────────────────────────────────────────────────────────────
-const StatCard = ({ label, value, icon: Icon, loading }) => (
-
-
- {label}
-
-
- {Icon && }
- {loading ? : value}
-
-
-);
-
-// ─── Main page ────────────────────────────────────────────────────────────────
-export default function TaskCompletions() {
- const navigate = useNavigate();
- const { taskListId, taskId } = useParams();
-
- const {
- task, taskList,
- completions, completionPagination, setCompletionPagination, completionLoading,
- completionAttributes,
- fetchTask, fetchTaskList, updateTask,
- fetchCompletions, reviewSubmission,
- archiveCompletion, restoreCompletion,
- bulkArchiveCompletions, bulkRestoreCompletions,
- } = useAdminTask();
-
- const [showArchived, setShowArchived] = useState(false);
- const [togglingSubmissions, setTogglingSubmissions] = useState(false);
- const [archiveTarget, setArchiveTarget] = useState(null);
- const [restoreTarget, setRestoreTarget] = useState(null);
- const [bulkArchiveIds, setBulkArchiveIds] = useState(null);
- const [bulkRestoreIds, setBulkRestoreIds] = useState(null);
- const [reviewTarget, setReviewTarget] = useState(null);
- const [reviewNote, setReviewNote] = useState('');
-
- const tableRefsRef = useRef({
- getFilters: () => [], getSort: () => [], resetSelection: () => {}, tableInstance: null,
- });
-
- // ── Initial fetch ─────────────────────────────────────────────────────────
- useEffect(() => {
- fetchTaskList(taskListId);
- fetchTask(taskListId, taskId);
- fetchCompletions(taskListId, taskId, { page: 1, limit: 10 });
- }, [taskListId, taskId]);
-
- const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
-
- // ── Toggle accepting submissions ─────────────────────────────────────────
- async function handleToggleAcceptingSubmissions(v) {
- setTogglingSubmissions(true);
- try {
- await updateTask(taskListId, taskId, { accepts_submissions: v });
- await fetchTask(taskListId, taskId);
- } finally {
- setTogglingSubmissions(false);
- }
- }
-
- // ── Fetch handler ─────────────────────────────────────────────────────────
- const handleFetch = useCallback((params) => {
- return fetchCompletions(taskListId, taskId, params);
- }, [fetchCompletions, taskListId, taskId]);
-
- // ── Toggle archived ───────────────────────────────────────────────────────
- const handleToggleArchived = () => {
- const next = !showArchived;
- setShowArchived(next);
- fetchCompletions(taskListId, taskId, {
- page: 1,
- limit: completionPagination?.limit ?? 10,
- filters: tableRefsRef.current.getFilters(),
- sort: tableRefsRef.current.getSort(),
- });
- };
-
- // ── After mutation ────────────────────────────────────────────────────────
- const afterMutation = () => {
- tableRefsRef.current.resetSelection?.();
- fetchCompletions(taskListId, taskId, {
- page: 1,
- limit: completionPagination?.limit ?? 10,
- filters: tableRefsRef.current.getFilters(),
- sort: tableRefsRef.current.getSort(),
- });
- };
-
- // ── Derived stats ─────────────────────────────────────────────────────────
- const totalRecords = completionPagination?.totalRecords ?? 0;
- const totalFiles = completions.reduce((acc, c) => acc + (c.files?.length ?? 0), 0);
- const uniqueUsers = new Set(completions.map((c) => c.user_id)).size;
- const latestDate = completions[0]?.submitted_at
- ? formatDate(completions[0].submitted_at)
- : '—';
-
- // ── Config ────────────────────────────────────────────────────────────────
- const exportConfig = useMemo(() => ({
- allData: completions,
- attributes: completionAttributes,
- filename: `${getTimestamp()}_Completions`,
- sheetName: 'Completions',
- }), [completions, completionAttributes]);
-
- const rowActions = buildRowActions({
- navigate,
- onArchive: (row) => setArchiveTarget(row),
- onRestore: (row) => setRestoreTarget(row),
- onReview: (row) => { setReviewTarget(row); setReviewNote(''); },
- showArchived,
- });
-
- const handleReview = async (status) => {
- if (!reviewTarget) return;
- const result = await reviewSubmission(taskListId, taskId, reviewTarget.completion_id, {
- status, review_note: reviewNote || null,
- });
- if (result) {
- setReviewTarget(null);
- afterMutation();
- }
- };
-
- const toolbarActions = buildToolbarActions({
- fetchCompletions,
- taskListId,
- taskId,
- pagination: completionPagination,
- exportConfig,
- showArchived,
- onToggleArchived: handleToggleArchived,
- getFilters: () => tableRefsRef.current.getFilters(),
- getSort: () => tableRefsRef.current.getSort(),
- getTableInstance: () => tableRefsRef.current.tableInstance,
- });
-
- const selectionActions = buildSelectionActions({
- exportConfig,
- showArchived,
- onBulkArchive: (ids) => setBulkArchiveIds(ids),
- onBulkRestore: (ids) => setBulkRestoreIds(ids),
- getTableInstance: () => tableRefsRef.current.tableInstance,
- });
-
- const columns = useMemo(
- () => buildDataColumns(completionAttributes, rowActions),
- [completionAttributes, rowActions]
- );
-
- // ── Breadcrumbs ───────────────────────────────────────────────────────────
- const breadcrumbs = [
- { label: 'Home', icon:
, to: '/admin' },
- { label: 'Task Lists', to: '/admin/taskList' },
- { label: taskList?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks` },
- { label: task?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view` },
- { label: 'Completions' },
- ];
-
- return (
-
-
- {/* ── Breadcrumb ────────────────────────────────────────────────── */}
-
-
- {/* ── Detail card ───────────────────────────────────────────────── */}
-
-
-
- {task
- ?
{task.name}
- :
- }
- {task
- ?
- {taskList?.name ?? '—'}
- {task.deadline ? ` · Due ${formatDateTime(task.deadline)}` : ''}
-
- :
- }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* ── DataTable ─────────────────────────────────────────────────── */}
-
Promise.resolve([])}
- toolbarActions={toolbarActions}
- selectionActions={selectionActions}
- columnPinning={columnPinning}
- onRefsReady={handleRefsReady}
- recordLabel="completion"
- emptyMessage="No completions yet."
- renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
-
- )}
- />
-
- {/* ── Single archive ────────────────────────────────────────────── */}
- !v && setArchiveTarget(null)}
- entity={archiveTarget}
- entityLabel="Completion"
- getName={(r) => r?.user?.name ?? 'this completion'}
- onArchive={(entity) => archiveCompletion(taskListId, taskId, entity?.completion_id)}
- loading={completionLoading}
- onSuccess={afterMutation}
- />
-
- {/* ── Single restore ────────────────────────────────────────────── */}
- !v && setRestoreTarget(null)}
- entity={restoreTarget}
- entityLabel="Completion"
- getName={(r) => r?.user?.name ?? 'this completion'}
- onRestore={(entity) => restoreCompletion(taskListId, taskId, entity?.completion_id)}
- loading={completionLoading}
- onSuccess={afterMutation}
- />
-
- {/* ── Bulk archive ──────────────────────────────────────────────── */}
- !v && setBulkArchiveIds(null)}
- ids={bulkArchiveIds ?? []}
- entityLabel="Completion"
- onArchive={({ ids }) => bulkArchiveCompletions(taskListId, taskId, ids)}
- loading={completionLoading}
- onSuccess={afterMutation}
- />
-
- {/* ── Bulk restore ──────────────────────────────────────────────── */}
- !v && setBulkRestoreIds(null)}
- ids={bulkRestoreIds ?? []}
- entityLabel="Completion"
- onRestore={({ ids }) => bulkRestoreCompletions(taskListId, taskId, ids)}
- loading={completionLoading}
- onSuccess={afterMutation}
- />
-
- {/* ── Review submission ────────────────────────────────────────────── */}
- !v && setReviewTarget(null)}>
-
-
- Review Submission
-
- {reviewTarget?.user?.name ?? 'This learner'}'s submission requires review before it counts as complete.
-
-
- {reviewTarget?.note && (
- {reviewTarget.note}
- )}
- {reviewTarget?.response_text && (
- {reviewTarget.response_text}
- )}
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/src/modules/admin/pages/task_list/task/ViewTask.jsx b/src/modules/admin/pages/task_list/task/ViewTask.jsx
index 97cf914..ad0fc56 100644
--- a/src/modules/admin/pages/task_list/task/ViewTask.jsx
+++ b/src/modules/admin/pages/task_list/task/ViewTask.jsx
@@ -1,5 +1,5 @@
-import { useEffect, useState } from 'react';
-import { useNavigate, useParams } from 'react-router-dom';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useAdminTask } from '@/contexts/AdminTaskContext';
import { useDateFormat } from '@/hooks/useDateFormat';
@@ -8,13 +8,33 @@ 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 { Switch } from '@/components/ui/switch';
+import { Textarea } from '@/components/ui/textarea';
+import {
+ AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
+ AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+
+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 {
ArrowLeft, Pencil, FileText, CalendarClock,
Link2, Upload, BookOpen, BookMarked, FileCheck2,
Info, GitPullRequest, Tag, Globe, Copy, File, Bookmark,
- ClipboardCheck,
+ ClipboardCheck, NotebookPen, Users, Paperclip, Check, X,
} from 'lucide-react';
+import { formatDate } from '@/utils/table.util';
+import { getTimestamp } from '@/utils/timestamp.util';
+
+import { buildDataColumns, columnPinning } from '@/modules/admin/config/task_list/task_completion/columns.config';
+import { buildToolbarActions } from '@/modules/admin/config/task_list/task_completion/toolbar.config';
+import { buildSelectionActions } from '@/modules/admin/config/task_list/task_completion/selection.config';
+import { buildRowActions as buildCompletionRowActions } from '@/modules/admin/config/task_list/task_completion/rowActions.config';
+
// ─── Same config as ViewTaskList — label, icon only, all styling via shadcn tokens
const REQUIREMENT_CONFIG = {
visit_link: { label: 'Visit Link', badgeLabel: 'Link', Icon: Link2 },
@@ -125,23 +145,166 @@ function TaskRequirementsSection({ requirements = [] }) {
);
}
+// ─── Stat card ────────────────────────────────────────────────────────────────
+const StatCard = ({ label, value, icon: Icon, loading }) => (
+
+
+ {label}
+
+
+ {Icon && }
+ {loading ? : value}
+
+
+);
+
+// ─── Tabs config ──────────────────────────────────────────────────────────────
+const TABS = [
+ { key: 'overview', label: 'Overview', icon: Info },
+ { key: 'completions', label: 'Completions', icon: NotebookPen },
+];
+
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ViewTask() {
const navigate = useNavigate();
const { taskListId, taskId } = useParams();
- const { fetchTask, fetchCompletions, completionPagination, completionLoading } = useAdminTask();
+ const [searchParams] = useSearchParams();
+
+ const {
+ task,
+ completions, completionPagination, setCompletionPagination, completionLoading, completionAttributes,
+ fetchTask, updateTask,
+ fetchCompletions, reviewSubmission,
+ archiveCompletion, restoreCompletion,
+ bulkArchiveCompletions, bulkRestoreCompletions,
+ } = useAdminTask();
const { fmtDateTime } = useDateFormat();
- const [task, setTask] = useState(null);
+ const [activeTab, setActiveTab] = useState(
+ searchParams.get('tab') === 'completions' ? 'completions' : 'overview'
+ );
+ const [togglingSubmissions, setTogglingSubmissions] = useState(false);
+
+ const [showArchived, setShowArchived] = useState(false);
+ const [archiveTarget, setArchiveTarget] = useState(null);
+ const [restoreTarget, setRestoreTarget] = useState(null);
+ const [bulkArchiveIds, setBulkArchiveIds] = useState(null);
+ const [bulkRestoreIds, setBulkRestoreIds] = useState(null);
+ const [reviewTarget, setReviewTarget] = useState(null);
+ const [reviewNote, setReviewNote] = useState('');
+
+ const tableRefsRef = useRef({
+ getFilters: () => [], getSort: () => [], resetSelection: () => {}, tableInstance: null,
+ });
useEffect(() => {
- fetchTask(taskListId, taskId).then((data) => {
- if (!data) return;
- setTask(data);
- });
- fetchCompletions(taskListId, taskId, { page: 1, limit: 1 });
+ fetchTask(taskListId, taskId);
}, [taskListId, taskId]);
+ const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
+
+ // ── Toggle accepting submissions ─────────────────────────────────────────
+ async function handleToggleAcceptingSubmissions(v) {
+ setTogglingSubmissions(true);
+ try {
+ await updateTask(taskListId, taskId, { accepts_submissions: v });
+ await fetchTask(taskListId, taskId);
+ } finally {
+ setTogglingSubmissions(false);
+ }
+ }
+
+ // ── Fetch handler ─────────────────────────────────────────────────────────
+ const handleFetch = useCallback((params) => {
+ return fetchCompletions(taskListId, taskId, params);
+ }, [fetchCompletions, taskListId, taskId]);
+
+ // ── Toggle archived ───────────────────────────────────────────────────────
+ const handleToggleArchived = () => {
+ const next = !showArchived;
+ setShowArchived(next);
+ fetchCompletions(taskListId, taskId, {
+ page: 1,
+ limit: completionPagination?.limit ?? 10,
+ filters: tableRefsRef.current.getFilters(),
+ sort: tableRefsRef.current.getSort(),
+ });
+ };
+
+ // ── After mutation ────────────────────────────────────────────────────────
+ const afterMutation = () => {
+ tableRefsRef.current.resetSelection?.();
+ fetchCompletions(taskListId, taskId, {
+ page: 1,
+ limit: completionPagination?.limit ?? 10,
+ filters: tableRefsRef.current.getFilters(),
+ sort: tableRefsRef.current.getSort(),
+ });
+ };
+
+ // ── Derived stats ─────────────────────────────────────────────────────────
+ const totalRecords = completionPagination?.totalRecords ?? 0;
+ const totalFiles = completions.reduce((acc, c) => acc + (c.files?.length ?? 0), 0);
+ const uniqueUsers = new Set(completions.map((c) => c.user_id)).size;
+ const latestDate = completions[0]?.submitted_at
+ ? formatDate(completions[0].submitted_at)
+ : '—';
+
+ // ── Config ────────────────────────────────────────────────────────────────
+ const exportConfig = useMemo(() => ({
+ allData: completions,
+ attributes: completionAttributes,
+ filename: `${getTimestamp()}_Completions`,
+ sheetName: 'Completions',
+ }), [completions, completionAttributes]);
+
+ const rowActions = buildCompletionRowActions({
+ navigate,
+ taskListId,
+ taskId,
+ onArchive: (row) => setArchiveTarget(row),
+ onRestore: (row) => setRestoreTarget(row),
+ onReview: (row) => { setReviewTarget(row); setReviewNote(''); },
+ showArchived,
+ });
+
+ const handleReview = async (status) => {
+ if (!reviewTarget) return;
+ const result = await reviewSubmission(taskListId, taskId, reviewTarget.completion_id, {
+ status, review_note: reviewNote || null,
+ });
+ if (result) {
+ setReviewTarget(null);
+ afterMutation();
+ }
+ };
+
+ const toolbarActions = buildToolbarActions({
+ fetchCompletions,
+ taskListId,
+ taskId,
+ pagination: completionPagination,
+ exportConfig,
+ showArchived,
+ onToggleArchived: handleToggleArchived,
+ getFilters: () => tableRefsRef.current.getFilters(),
+ getSort: () => tableRefsRef.current.getSort(),
+ getTableInstance: () => tableRefsRef.current.tableInstance,
+ });
+
+ const selectionActions = buildSelectionActions({
+ exportConfig,
+ showArchived,
+ onBulkArchive: (ids) => setBulkArchiveIds(ids),
+ onBulkRestore: (ids) => setBulkRestoreIds(ids),
+ getTableInstance: () => tableRefsRef.current.tableInstance,
+ });
+
+ const columns = useMemo(
+ () => buildDataColumns(completionAttributes, rowActions),
+ [completionAttributes, rowActions]
+ );
+
if (!task) return (
@@ -154,8 +317,8 @@ export default function ViewTask() {
const requirements = task.requirements ?? [];
return (
-
-
+
+
{/* Header */}
@@ -181,86 +344,226 @@ export default function ViewTask() {
- {/* Main content — plain div avoids Card overflow:hidden clipping */}
-
-
+ {/* Underline tabs */}
+
+
+ {TABS.map(({ key, label, icon: Icon }) => (
+
+ ))}
+
+
- {/* Description */}
- {task.description ? (
-
- ) : (
-
No description provided.
- )}
+ {/* ── Overview tab ──────────────────────────────────────────── */}
+ {activeTab === 'overview' && (
+ // plain div avoids Card overflow:hidden clipping
+
+
- {/* Deadline */}
- {task.deadline && (
- <>
-
-
-
-
- Deadline:{' '}
-
- {fmtDateTime(task.deadline)}
-
-
-
- >
- )}
+ {/* Description */}
+ {task.description ? (
+
+ ) : (
+
No description provided.
+ )}
- {/* Status */}
- {task.status && (
- <>
+ {/* Deadline */}
+ {task.deadline && (
+ <>
+
+
+
+
+ Deadline:{' '}
+
+ {fmtDateTime(task.deadline)}
+
+
+
+ >
+ )}
+
+ {/* Status */}
+ {task.status && (
+ <>
+
+
+
+ Status
+
+
+ {task.status}
+
+
+ >
+ )}
+
+ {/* Accepting submissions */}
- Status
+ Accepting submissions
-
- {task.status}
-
+
+
+
+
+
+ {/* Requirements */}
+
- >
- )}
- {/* Completions */}
-
-
-
- Completions
-
-
-
- {completionLoading
- ? '…'
- : `${completionPagination.totalRecords} submission${completionPagination.totalRecords === 1 ? '' : 's'}`}
-
-
+ )}
-
-
- {/* Requirements */}
-
-
-
Requirements
+ {/* ── Completions tab ───────────────────────────────────────── */}
+ {activeTab === 'completions' && (
+
-
-
+
Promise.resolve([])}
+ toolbarActions={toolbarActions}
+ selectionActions={selectionActions}
+ columnPinning={columnPinning}
+ onRefsReady={handleRefsReady}
+ recordLabel="completion"
+ emptyMessage="No completions yet."
+ renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
+
+ )}
+ />
+
+ )}
+
+ {/* ── Single archive ────────────────────────────────────────────── */}
+
!v && setArchiveTarget(null)}
+ entity={archiveTarget}
+ entityLabel="Completion"
+ getName={(r) => r?.user?.name ?? 'this completion'}
+ onArchive={(entity) => archiveCompletion(taskListId, taskId, entity?.completion_id)}
+ loading={completionLoading}
+ onSuccess={afterMutation}
+ />
+
+ {/* ── Single restore ────────────────────────────────────────────── */}
+ !v && setRestoreTarget(null)}
+ entity={restoreTarget}
+ entityLabel="Completion"
+ getName={(r) => r?.user?.name ?? 'this completion'}
+ onRestore={(entity) => restoreCompletion(taskListId, taskId, entity?.completion_id)}
+ loading={completionLoading}
+ onSuccess={afterMutation}
+ />
+
+ {/* ── Bulk archive ──────────────────────────────────────────────── */}
+ !v && setBulkArchiveIds(null)}
+ ids={bulkArchiveIds ?? []}
+ entityLabel="Completion"
+ onArchive={({ ids }) => bulkArchiveCompletions(taskListId, taskId, ids)}
+ loading={completionLoading}
+ onSuccess={afterMutation}
+ />
+
+ {/* ── Bulk restore ──────────────────────────────────────────────── */}
+ !v && setBulkRestoreIds(null)}
+ ids={bulkRestoreIds ?? []}
+ entityLabel="Completion"
+ onRestore={({ ids }) => bulkRestoreCompletions(taskListId, taskId, ids)}
+ loading={completionLoading}
+ onSuccess={afterMutation}
+ />
+
+ {/* ── Review submission ────────────────────────────────────────────── */}
+ !v && setReviewTarget(null)}>
+
+
+ Review Submission
+
+ {reviewTarget?.user?.name ?? 'This learner'}'s submission requires review before it counts as complete.
+
+
+ {reviewTarget?.note && (
+ {reviewTarget.note}
+ )}
+ {reviewTarget?.response_text && (
+ {reviewTarget.response_text}
+ )}
+
+
+
);
-}
\ No newline at end of file
+}
diff --git a/src/modules/admin/pages/task_list/task/ViewTaskCompletion.jsx b/src/modules/admin/pages/task_list/task/ViewTaskCompletion.jsx
index 3a8852c..f62a6b5 100644
--- a/src/modules/admin/pages/task_list/task/ViewTaskCompletion.jsx
+++ b/src/modules/admin/pages/task_list/task/ViewTaskCompletion.jsx
@@ -102,7 +102,7 @@ export default function ViewTaskCompletion() {
{ label: 'Task Lists', to: '/admin/taskList' },
{ label: taskList?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks` },
{ label: task?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view` },
- { label: 'Completions', to: `/admin/taskList/${taskListId}/tasks/${taskId}/completions` },
+ { label: 'Completions', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view?tab=completions` },
{ label: 'View' },
];
@@ -115,7 +115,7 @@ export default function ViewTaskCompletion() {