!v && setRestoreIds(null)}
+ ids={restoreIds ?? []}
+ entityLabel="Task List"
+ onRestore={(ids) => bulkRestoreTaskLists(ids)}
+ loading={loading}
+ onSuccess={handleSuccess}
+ />
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/task_list/archive/columns.config.jsx b/src/modules/admin/config/task_list/archive/columns.config.jsx
new file mode 100644
index 0000000..e495bfd
--- /dev/null
+++ b/src/modules/admin/config/task_list/archive/columns.config.jsx
@@ -0,0 +1,43 @@
+// config/columns.config.jsx
+// Column definitions and pinning config for the Task List table.
+
+import { buildColumns } from "@/utils/table.util";
+import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
+import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
+
+export const columnPinning = {
+ right: ["actions"],
+ left: [],
+};
+
+const cellOverrides = {
+ // memberCount: (info) => {
+ // const count = parseInt(info.getValue() ?? 0, 10);
+ // return (
+ //
+ //
+ //
+ // {count} {count === 1 ? "member" : "members"}
+ //
+ //
+ // );
+ // },
+};
+
+
+/**
+ * Builds the full column array for the Users table.
+ *
+ * @param {Array} attributes Field definitions from the server (drives data columns)
+ * @param {Array} rowActions Row-level kebab action definitions
+ * @returns {Array} TanStack column definitions
+ */
+export function buildDataColumns(attributes, rowActions) {
+ const visibleAttributes = attributes.filter((a) => !a.hidden);
+
+ return [
+ buildSelectionColumn(),
+ ...buildColumns(visibleAttributes, { cellOverrides }),
+ buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }),
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/task_list/archive/rowActions.config.jsx b/src/modules/admin/config/task_list/archive/rowActions.config.jsx
new file mode 100644
index 0000000..1438e71
--- /dev/null
+++ b/src/modules/admin/config/task_list/archive/rowActions.config.jsx
@@ -0,0 +1,18 @@
+import { RotateCcw, Eye } from "lucide-react";
+
+export function buildRowActions({ navigate, onRestore }) {
+ return [
+ {
+ key: "view",
+ label: "View",
+ icon: ,
+ onClick: (row) => navigate(`/admin/taskList/${row.task_list_id}/view`),
+ },
+ {
+ key: "restore",
+ label: "Restore",
+ icon: ,
+ onClick: (row) => onRestore(row),
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/task_list/archive/selection.config.jsx b/src/modules/admin/config/task_list/archive/selection.config.jsx
new file mode 100644
index 0000000..11bd773
--- /dev/null
+++ b/src/modules/admin/config/task_list/archive/selection.config.jsx
@@ -0,0 +1,31 @@
+import { RotateCcw } from "lucide-react";
+import { exportTableToExcel } from "@/utils/excel.util";
+import { Download } from "lucide-react";
+
+export function buildSelectionActions({
+ exportConfig,
+ onRestoreMany,
+ getTableInstance,
+}) {
+ return [
+ {
+ key: "export-selected",
+ label: "Export Selected",
+ icon: ,
+ onClick: () =>
+ exportTableToExcel({
+ ...exportConfig,
+ tableInstance: getTableInstance?.(),
+ selectedOnly: true,
+ }),
+ },
+ {
+ key: "bulk-restore",
+ label: "Restore Selected",
+ icon: ,
+ variant: "outline",
+ onClick: (selectedRows) =>
+ onRestoreMany(selectedRows.map((r) => r.task_list_id)),
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/task_list/archive/toolbar.config.jsx b/src/modules/admin/config/task_list/archive/toolbar.config.jsx
new file mode 100644
index 0000000..d73ea08
--- /dev/null
+++ b/src/modules/admin/config/task_list/archive/toolbar.config.jsx
@@ -0,0 +1,38 @@
+import { RefreshCw, Download } from "lucide-react";
+import { exportTableToExcel } from "@/utils/excel.util";
+
+export function buildToolbarActions({
+ fetchArchivedTaskLists,
+ pagination,
+ exportConfig,
+ getFilters,
+ getSort,
+ getTableInstance,
+}) {
+ return [
+ {
+ key: "refresh",
+ type: "button",
+ icon: ,
+ label: "Refresh",
+ onClick: () =>
+ fetchArchivedTaskLists({
+ page: 1,
+ limit: pagination?.limit ?? 10,
+ filters: getFilters?.() ?? [],
+ sort: getSort?.() ?? [],
+ }),
+ },
+ {
+ key: "export",
+ type: "button",
+ icon: ,
+ label: "Export",
+ onClick: (table) =>
+ exportTableToExcel({
+ ...exportConfig,
+ tableInstance: table ?? getTableInstance?.(),
+ }),
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/task_list/columns.config.jsx b/src/modules/admin/config/task_list/columns.config.jsx
new file mode 100644
index 0000000..e495bfd
--- /dev/null
+++ b/src/modules/admin/config/task_list/columns.config.jsx
@@ -0,0 +1,43 @@
+// config/columns.config.jsx
+// Column definitions and pinning config for the Task List table.
+
+import { buildColumns } from "@/utils/table.util";
+import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
+import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
+
+export const columnPinning = {
+ right: ["actions"],
+ left: [],
+};
+
+const cellOverrides = {
+ // memberCount: (info) => {
+ // const count = parseInt(info.getValue() ?? 0, 10);
+ // return (
+ //
+ //
+ //
+ // {count} {count === 1 ? "member" : "members"}
+ //
+ //
+ // );
+ // },
+};
+
+
+/**
+ * Builds the full column array for the Users table.
+ *
+ * @param {Array} attributes Field definitions from the server (drives data columns)
+ * @param {Array} rowActions Row-level kebab action definitions
+ * @returns {Array} TanStack column definitions
+ */
+export function buildDataColumns(attributes, rowActions) {
+ const visibleAttributes = attributes.filter((a) => !a.hidden);
+
+ return [
+ buildSelectionColumn(),
+ ...buildColumns(visibleAttributes, { cellOverrides }),
+ buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }),
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/task_list/rowActions.config.jsx b/src/modules/admin/config/task_list/rowActions.config.jsx
new file mode 100644
index 0000000..e574c1d
--- /dev/null
+++ b/src/modules/admin/config/task_list/rowActions.config.jsx
@@ -0,0 +1,35 @@
+import { Eye, Pencil, Archive, ArchiveRestore, NotebookPen, Info } from "lucide-react";
+
+export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
+ return [
+ {
+ key: "edit",
+ label: "View Info",
+ icon: ,
+ onClick: (row) => navigate(`${row.task_list_id}/view`),
+ },
+ {
+ key: "edit",
+ label: "Edit Info",
+ icon: ,
+ onClick: (row) => navigate(`${row.task_list_id}/edit`),
+ },
+ {
+ key: "view",
+ label: "View Tasks",
+ icon: ,
+ onClick: (row) => navigate(`${row.task_list_id}/tasks`),
+ separator: true,
+ className: "text-sky-800"
+ },
+ {
+ key: "archive",
+ label: "Archive",
+ className: "text-destructive focus:text-destructive",
+ icon: ,
+ onClick: (row) => onArchive(row),
+ hidden: () => showArchived,
+ separator: true,
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/task_list/selection.config.jsx b/src/modules/admin/config/task_list/selection.config.jsx
new file mode 100644
index 0000000..3543fe2
--- /dev/null
+++ b/src/modules/admin/config/task_list/selection.config.jsx
@@ -0,0 +1,48 @@
+// config/selection.config.jsx
+import { Download, Archive, RotateCcw } from "lucide-react";
+import { exportTableToExcel } from "@/utils/excel.util";
+
+/**
+ * @param {Object} deps
+ * @param {Object} deps.exportConfig
+ * @param {boolean} deps.showArchived
+ * @param {Function} deps.onBulkArchive
+ * @param {Function} deps.onBulkRestore
+ * @param {Function} deps.getTableInstance
+ */
+export function buildSelectionActions({
+ exportConfig,
+ showArchived,
+ onBulkArchive,
+ onBulkRestore,
+ getTableInstance,
+}) {
+ return [
+ {
+ key: "export-selected",
+ label: "Export",
+ icon: ,
+ onClick: (rows, table) =>
+ exportTableToExcel({
+ ...exportConfig,
+ selectedRows: rows,
+ tableInstance: table ?? getTableInstance?.(),
+ }),
+ },
+ {
+ key: showArchived ? "restore-selected" : "archive-selected",
+ label: showArchived ? "Restore" : "Archive",
+ icon: showArchived ? (
+
+ ) : (
+
+ ),
+ className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
+ onClick: (rows) => {
+ const ids = rows.map((r) => r.task_id).filter(Boolean);
+ if (!ids.length) return;
+ showArchived ? onBulkRestore?.(ids) : onBulkArchive?.(ids);
+ },
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/task_list/task/archive/columns.config.jsx b/src/modules/admin/config/task_list/task/archive/columns.config.jsx
new file mode 100644
index 0000000..889c7c0
--- /dev/null
+++ b/src/modules/admin/config/task_list/task/archive/columns.config.jsx
@@ -0,0 +1,28 @@
+// config/columns.config.jsx
+// Column definitions and pinning config for the Users table.
+
+import { buildColumns } from "@/utils/table.util";
+import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
+import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
+
+export const columnPinning = {
+ right: ["actions"],
+ left: [],
+};
+
+/**
+ * Builds the full column array for the Users table.
+ *
+ * @param {Array} attributes Field definitions from the server (drives data columns)
+ * @param {Array} rowActions Row-level kebab action definitions
+ * @returns {Array} TanStack column definitions
+ */
+export function buildDataColumns(attributes, rowActions) {
+ const visibleAttributes = attributes.filter((a) => !a.hidden);
+
+ return [
+ buildSelectionColumn(),
+ ...buildColumns(visibleAttributes),
+ buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }),
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/task_list/task/archive/rowActions.config.jsx b/src/modules/admin/config/task_list/task/archive/rowActions.config.jsx
new file mode 100644
index 0000000..ac6a925
--- /dev/null
+++ b/src/modules/admin/config/task_list/task/archive/rowActions.config.jsx
@@ -0,0 +1,24 @@
+// config/rowActions.config.jsx
+// Per-row kebab menu action definitions for the Users table.
+//
+// Each onClick receives the row's data object from buildRowActionsColumn.
+
+import { RotateCcw } from "lucide-react";
+
+/**
+ * @param {Object} deps
+ * @param {Function} deps.navigate React Router navigate
+ * @param {Function} deps.archiveUser Archive handler from useManagement
+ * @returns {Array} rowActions
+ */
+export function buildRowActions({ navigate, onRestore }) {
+ return [
+ {
+ key: "restore",
+ label: "Restore",
+ icon: ,
+ onClick: (row) => onRestore(row),
+ hidden: (row) => row.is_active,
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/task_list/task/archive/selection.config.jsx b/src/modules/admin/config/task_list/task/archive/selection.config.jsx
new file mode 100644
index 0000000..af85009
--- /dev/null
+++ b/src/modules/admin/config/task_list/task/archive/selection.config.jsx
@@ -0,0 +1,34 @@
+// config/selection.config.jsx
+import { Download, Archive, Trash2 } from "lucide-react";
+import { exportTableToExcel } from "@/utils/excel.util";
+
+/**
+ * @param {Object} deps
+ * @param {Object} deps.exportConfig { allData, attributes, filename, sheetName }
+ * @param {Function} deps.archiveUser Archive handler from useManagement
+ * @param {Function} deps.deleteUser Delete handler from useManagement
+ */
+export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers, getTableInstance }) {
+ return [
+ {
+ key: "export-selected",
+ label: "Export",
+ icon: ,
+ onClick: (rows, table) =>
+ exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table ?? getTableInstance() }),
+ },
+ {
+ key: "archive-selected",
+ label: "Archive",
+ icon: ,
+ className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
+ onClick: (rows) => {
+ const ids = rows.map((r) => r.user_id);
+ ids.length === 1
+ ? archiveUser(rows[0]) // opens single dialog
+ : archiveUsers(ids); // opens bulk dialog
+ },
+ hidden: (rows) => rows.every((r) => r.status === "archived"),
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/task_list/task/archive/toolbar.config.jsx b/src/modules/admin/config/task_list/task/archive/toolbar.config.jsx
new file mode 100644
index 0000000..a6c7b9b
--- /dev/null
+++ b/src/modules/admin/config/task_list/task/archive/toolbar.config.jsx
@@ -0,0 +1,57 @@
+// ─── config/toolbar.config.jsx ────────────────────────────────────────────────
+import { RefreshCw, Download, Plus, Archive } from "lucide-react";
+import { exportTableToExcel } from "@/utils/excel.util";
+
+/**
+ * @param {Object} deps
+ * @param {Function} deps.fetchTasks
+ * @param {string} deps.taskListId
+ * @param {Object} deps.pagination
+ * @param {Object} deps.exportConfig
+ * @param {Function} deps.navigate
+ * @param {boolean} deps.showArchived
+ * @param {Function} deps.onToggleArchived
+ * @param {Function} deps.getFilters
+ * @param {Function} deps.getSort
+ * @param {Function} deps.getTableInstance
+ */
+export function buildToolbarActions({
+ fetchTasks,
+ taskListId,
+ pagination,
+ exportConfig,
+ navigate,
+ showArchived,
+ onToggleArchived,
+ getFilters,
+ getSort,
+ getTableInstance,
+}) {
+ return [
+ {
+ key: "refresh",
+ type: "button",
+ icon: ,
+ label: "Refresh",
+ onClick: () =>
+ fetchTasks(taskId, {
+ page: 1,
+ limit: pagination?.limit ?? 10,
+ filters: getFilters?.() ?? [],
+ sort: getSort?.() ?? [],
+ archived: showArchived,
+ }),
+ },
+ {
+ key: "export",
+ type: "button",
+ icon: ,
+ label: "Export",
+ onClick: (table) =>
+ exportTableToExcel({
+ ...exportConfig,
+ tableInstance: table ?? getTableInstance?.(),
+ }),
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/task_list/task/columns.config.jsx b/src/modules/admin/config/task_list/task/columns.config.jsx
new file mode 100644
index 0000000..889c7c0
--- /dev/null
+++ b/src/modules/admin/config/task_list/task/columns.config.jsx
@@ -0,0 +1,28 @@
+// config/columns.config.jsx
+// Column definitions and pinning config for the Users table.
+
+import { buildColumns } from "@/utils/table.util";
+import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
+import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
+
+export const columnPinning = {
+ right: ["actions"],
+ left: [],
+};
+
+/**
+ * Builds the full column array for the Users table.
+ *
+ * @param {Array} attributes Field definitions from the server (drives data columns)
+ * @param {Array} rowActions Row-level kebab action definitions
+ * @returns {Array} TanStack column definitions
+ */
+export function buildDataColumns(attributes, rowActions) {
+ const visibleAttributes = attributes.filter((a) => !a.hidden);
+
+ return [
+ buildSelectionColumn(),
+ ...buildColumns(visibleAttributes),
+ buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }),
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/task_list/task/rowActions.config.jsx b/src/modules/admin/config/task_list/task/rowActions.config.jsx
new file mode 100644
index 0000000..2bbc912
--- /dev/null
+++ b/src/modules/admin/config/task_list/task/rowActions.config.jsx
@@ -0,0 +1,27 @@
+import { Eye, Pencil, Archive, ArchiveRestore, Info } from "lucide-react";
+
+export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
+ return [
+ {
+ key: "edit",
+ label: "View Info",
+ icon: ,
+ onClick: (row) => navigate(`${row.task_id}/view`),
+ },
+ {
+ key: "edit",
+ label: "Edit Info",
+ icon: ,
+ onClick: (row) => navigate(`${row.task_id}/edit`),
+ },
+ {
+ key: "archive",
+ label: "Archive",
+ className: "text-destructive focus:text-destructive",
+ icon: ,
+ onClick: (row) => onArchive(row),
+ hidden: () => showArchived,
+ separator: true,
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/task_list/task/selection.config.jsx b/src/modules/admin/config/task_list/task/selection.config.jsx
new file mode 100644
index 0000000..af85009
--- /dev/null
+++ b/src/modules/admin/config/task_list/task/selection.config.jsx
@@ -0,0 +1,34 @@
+// config/selection.config.jsx
+import { Download, Archive, Trash2 } from "lucide-react";
+import { exportTableToExcel } from "@/utils/excel.util";
+
+/**
+ * @param {Object} deps
+ * @param {Object} deps.exportConfig { allData, attributes, filename, sheetName }
+ * @param {Function} deps.archiveUser Archive handler from useManagement
+ * @param {Function} deps.deleteUser Delete handler from useManagement
+ */
+export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers, getTableInstance }) {
+ return [
+ {
+ key: "export-selected",
+ label: "Export",
+ icon: ,
+ onClick: (rows, table) =>
+ exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table ?? getTableInstance() }),
+ },
+ {
+ key: "archive-selected",
+ label: "Archive",
+ icon: ,
+ className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
+ onClick: (rows) => {
+ const ids = rows.map((r) => r.user_id);
+ ids.length === 1
+ ? archiveUser(rows[0]) // opens single dialog
+ : archiveUsers(ids); // opens bulk dialog
+ },
+ hidden: (rows) => rows.every((r) => r.status === "archived"),
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/task_list/task/toolbar.config.jsx b/src/modules/admin/config/task_list/task/toolbar.config.jsx
new file mode 100644
index 0000000..9103bca
--- /dev/null
+++ b/src/modules/admin/config/task_list/task/toolbar.config.jsx
@@ -0,0 +1,76 @@
+// ─── config/toolbar.config.jsx ────────────────────────────────────────────────
+import { RefreshCw, Download, Plus, Archive } from "lucide-react";
+import { exportTableToExcel } from "@/utils/excel.util";
+
+/**
+ * @param {Object} deps
+ * @param {Function} deps.fetchTasks
+ * @param {string} deps.taskListId
+ * @param {Object} deps.pagination
+ * @param {Object} deps.exportConfig
+ * @param {Function} deps.navigate
+ * @param {boolean} deps.showArchived
+ * @param {Function} deps.onToggleArchived
+ * @param {Function} deps.getFilters
+ * @param {Function} deps.getSort
+ * @param {Function} deps.getTableInstance
+ */
+export function buildToolbarActions({
+ fetchTasks,
+ taskListId,
+ pagination,
+ exportConfig,
+ navigate,
+ showArchived,
+ onToggleArchived,
+ getFilters,
+ getSort,
+ getTableInstance,
+}) {
+ return [
+ {
+ key: "refresh",
+ type: "button",
+ icon: ,
+ label: "Refresh",
+ onClick: () =>
+ fetchTasks(taskId, {
+ page: 1,
+ limit: pagination?.limit ?? 10,
+ filters: getFilters?.() ?? [],
+ sort: getSort?.() ?? [],
+ archived: showArchived,
+ }),
+ },
+ {
+ key: "export",
+ type: "button",
+ icon: ,
+ label: "Export",
+ onClick: (table) =>
+ exportTableToExcel({
+ ...exportConfig,
+ tableInstance: table ?? getTableInstance?.(),
+ }),
+ },
+ {
+ key: "add-task",
+ type: "button",
+ icon: ,
+ label: "Create Task",
+ variant: "default",
+ className: "text-primary-foreground",
+ onClick: () => navigate(`/admin/taskList/${taskListId}/tasks/create`),
+ },
+ {
+ key: "toggle-archived-task",
+ type: "button",
+ icon: ,
+ label: showArchived ? "Active Tasks" : "Archived Tasks",
+ variant: "secondary",
+ className: "border border-border",
+ // onClick: onToggleArchived,
+ onClick: () => navigate(`/admin/taskList/${taskListId}/tasks/archived`),
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/task_list/toolbar.config.jsx b/src/modules/admin/config/task_list/toolbar.config.jsx
new file mode 100644
index 0000000..e4e608f
--- /dev/null
+++ b/src/modules/admin/config/task_list/toolbar.config.jsx
@@ -0,0 +1,76 @@
+// ─── config/toolbar.config.jsx ────────────────────────────────────────────────
+import { RefreshCw, Download, Plus, Archive } from "lucide-react";
+import { exportTableToExcel } from "@/utils/excel.util";
+
+/**
+ * @param {Object} deps
+ * @param {Function} deps.fetchTasks
+ * @param {string} deps.taskListId
+ * @param {Object} deps.pagination
+ * @param {Object} deps.exportConfig
+ * @param {Function} deps.navigate
+ * @param {boolean} deps.showArchived
+ * @param {Function} deps.onToggleArchived
+ * @param {Function} deps.getFilters
+ * @param {Function} deps.getSort
+ * @param {Function} deps.getTableInstance
+ */
+export function buildToolbarActions({
+ fetchTasks,
+ fetchArchivedTaskLists,
+ taskListId,
+ pagination,
+ exportConfig,
+ navigate,
+ showArchived,
+ onToggleArchived,
+ getFilters,
+ getSort,
+ getTableInstance,
+}) {
+ return [
+ {
+ key: "refresh",
+ type: "button",
+ icon: ,
+ label: "Refresh",
+ onClick: () =>
+ fetchTasks(taskListId, {
+ page: 1,
+ limit: pagination?.limit ?? 10,
+ filters: getFilters?.() ?? [],
+ sort: getSort?.() ?? [],
+ archived: showArchived,
+ }),
+ },
+ {
+ key: "export",
+ type: "button",
+ icon: ,
+ label: "Export",
+ onClick: (table) =>
+ exportTableToExcel({
+ ...exportConfig,
+ tableInstance: table ?? getTableInstance?.(),
+ }),
+ },
+ {
+ key: "add-task",
+ type: "button",
+ icon: ,
+ label: "Create Task List",
+ variant: "default",
+ className: "text-primary-foreground",
+ onClick: () => navigate(`/admin/taskList/create`),
+ },
+ {
+ key: "toggle-archived-task",
+ type: "button",
+ icon: ,
+ label: showArchived ? "Active Task Lists" : "Archived Task List",
+ variant: "secondary",
+ className: "border border-border",
+ onClick: () => navigate("/admin/taskList/archived"),
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/pages/task_list/ArchiveTaskList.jsx b/src/modules/admin/pages/task_list/ArchiveTaskList.jsx
new file mode 100644
index 0000000..94a1afd
--- /dev/null
+++ b/src/modules/admin/pages/task_list/ArchiveTaskList.jsx
@@ -0,0 +1,24 @@
+import { House } from "lucide-react";
+import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
+import ArchiveTaskListTable from "@/modules/admin/components/task/ArchiveTaskListTable";
+
+export default function ArchiveTaskList() {
+ const items = [
+ { label: "Home", icon: , to: "/admin" },
+ { label: "Task List", to: "/admin/taskList" },
+ { label: "Archived" },
+ ];
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/modules/admin/pages/task_list/CreateTaskList.jsx b/src/modules/admin/pages/task_list/CreateTaskList.jsx
new file mode 100644
index 0000000..2af96bc
--- /dev/null
+++ b/src/modules/admin/pages/task_list/CreateTaskList.jsx
@@ -0,0 +1,118 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { useAdminTask } from '@/contexts/AdminTaskContext';
+import GroupMultiSelect from '@/components/generic/GroupMultiSelect';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+
+export default function CreateTaskList() {
+ const navigate = useNavigate();
+ const { createTaskList, assignGroups, loading } = useAdminTask();
+
+ const [form, setForm] = useState({ name: '', description: '' });
+ const [selectedGroupIds, setSelectedGroupIds] = useState([]);
+ const [errors, setErrors] = useState({});
+
+ const validate = () => {
+ const e = {};
+ if (!form.name.trim()) e.name = 'Task list name is required.';
+ setErrors(e);
+ return Object.keys(e).length === 0;
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!validate()) return;
+
+ const created = await createTaskList({
+ name: form.name.trim(),
+ description: form.description.trim() || null,
+ });
+
+ if (!created) return; // createTaskList already toasts on error
+
+ // Assign selected groups if any — non-blocking: navigate regardless
+ if (selectedGroupIds.length > 0) {
+ await assignGroups(created.task_list_id, selectedGroupIds);
+ }
+ };
+
+ return (
+
+
+
+ Create Task List
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/modules/admin/pages/task_list/EditTaskList.jsx b/src/modules/admin/pages/task_list/EditTaskList.jsx
new file mode 100644
index 0000000..e9a7a47
--- /dev/null
+++ b/src/modules/admin/pages/task_list/EditTaskList.jsx
@@ -0,0 +1,186 @@
+import { useEffect, useState, useMemo } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import { useAdminTask } from '@/contexts/AdminTaskContext';
+import GroupMultiSelect from '@/components/generic/GroupMultiSelect';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { Card, CardContent } from '@/components/ui/card';
+import { Skeleton } from '@/components/ui/skeleton';
+import { ArrowLeft } from 'lucide-react';
+
+export default function EditTaskList() {
+ const navigate = useNavigate();
+ const { taskListId } = useParams();
+ const { fetchTaskList, updateTaskList, assignGroups, unassignGroups, loading } = useAdminTask();
+
+ const [form, setForm] = useState(null);
+ const [errors, setErrors] = useState({});
+
+ // ── Original values to diff against ──────────────────────────────────────
+ const [original, setOriginal] = useState(null);
+ const [originalGroupIds, setOriginalGroupIds] = useState([]);
+ const [selectedGroupIds, setSelectedGroupIds] = useState([]);
+
+ useEffect(() => {
+ fetchTaskList(taskListId).then((data) => {
+ if (!data) return;
+
+ const initialForm = {
+ name: data.name ?? '',
+ description: data.description ?? '',
+ };
+
+ setForm(initialForm);
+ setOriginal(initialForm);
+
+ const ids = (data.groups ?? []).map((g) => g.group_id);
+ setOriginalGroupIds(ids);
+ setSelectedGroupIds(ids);
+ });
+ }, [taskListId]);
+
+ // ── Dirty check — true only when something actually changed ───────────────
+ const isDirty = useMemo(() => {
+ if (!form || !original) return false;
+
+ const formChanged =
+ form.name.trim() !== original.name.trim() ||
+ (form.description.trim() || null) !== (original.description.trim() || null);
+
+ const groupsChanged =
+ selectedGroupIds.length !== originalGroupIds.length ||
+ selectedGroupIds.some((id) => !originalGroupIds.includes(id));
+
+ return formChanged || groupsChanged;
+ }, [form, original, selectedGroupIds, originalGroupIds]);
+
+ const validate = () => {
+ const e = {};
+ if (!form?.name?.trim()) e.name = 'Task list name is required.';
+ setErrors(e);
+ return Object.keys(e).length === 0;
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!validate()) return;
+
+ // ── 1. Update name / description ──────────────────────────────────────
+ const updated = await updateTaskList(taskListId, {
+ name: form.name.trim(),
+ description: form.description.trim() || null,
+ });
+
+ if (!updated) return;
+
+ // ── 2. Diff groups ────────────────────────────────────────────────────
+ const toAssign = selectedGroupIds.filter((id) => !originalGroupIds.includes(id));
+ const toUnassign = originalGroupIds.filter((id) => !selectedGroupIds.includes(id));
+
+ await Promise.all([
+ toAssign.length ? assignGroups(taskListId, toAssign) : Promise.resolve(),
+ toUnassign.length ? unassignGroups(taskListId, toUnassign) : Promise.resolve(),
+ ]);
+
+ navigate(`/admin/taskList/${taskListId}`);
+ };
+
+ // ── Loading skeleton ──────────────────────────────────────────────────────
+ if (!form) return (
+
+
+
+
+
+
+ );
+
+ return (
+
+
+
+
+
Edit Task List
+
Update task list.
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/modules/admin/pages/task_list/TaskList.jsx b/src/modules/admin/pages/task_list/TaskList.jsx
new file mode 100644
index 0000000..dd08e1f
--- /dev/null
+++ b/src/modules/admin/pages/task_list/TaskList.jsx
@@ -0,0 +1,23 @@
+import { House } from "lucide-react";
+import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
+import TaskListTable from "../../components/task/TaskListTable";
+
+export default function TaskList() {
+ const items = [
+ { label: "Home", icon: , to: "/admin" },
+ { label: "Task List" },
+ ];
+
+ return (
+
+ );
+}
diff --git a/src/modules/admin/pages/task_list/ViewTaskList.jsx b/src/modules/admin/pages/task_list/ViewTaskList.jsx
new file mode 100644
index 0000000..08d6572
--- /dev/null
+++ b/src/modules/admin/pages/task_list/ViewTaskList.jsx
@@ -0,0 +1,298 @@
+import { useEffect, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import { useAdminTask } from '@/contexts/AdminTaskContext';
+
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Skeleton } from '@/components/ui/skeleton';
+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,
+} 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 },
+};
+
+// ─── Label / value row — fully themed by shadcn tokens ───────────────────────
+function MetaRow({ icon: Icon, label, children }) {
+ return (
+
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
+
+// ─── 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;
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+ {cfg.label}
+
+
+ {cfg.badgeLabel}
+
+
+
+ {/* Rows */}
+ {req.type === 'visit_link' && (
+ <>
+ {req.link_label && (
+
+ {req.link_label}
+
+ )}
+ {req.link_url && (
+
+
+ {req.link_url}
+
+
+ )}
+ >
+ )}
+
+ {req.type === 'upload_file' && (
+ <>
+ {req.max_file_count != null && (
+
+ {req.max_file_count}
+
+ )}
+ {req.allowed_file_types?.length > 0 && (
+
+ {req.allowed_file_types.join(', ')}
+
+ )}
+ >
+ )}
+
+ {['read_course', 'read_unit', 'read_lesson'].includes(req.type) && req.reference_label && (
+
+ {req.reference_label}
+
+ )}
+
+ );
+}
+
+// ─── Requirements section ─────────────────────────────────────────────────────
+function TaskRequirementsSection({ requirements = [] }) {
+ const sorted = [...requirements].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
+ return (
+
+
+ Requirements
+
+ {sorted.length > 0 ? (
+ sorted.map((req) => (
+
+ ))
+ ) : (
+
No other requirements.
+ )}
+
+ );
+}
+
+// ─── Page ─────────────────────────────────────────────────────────────────────
+export default function ViewTaskList() {
+ const navigate = useNavigate();
+ const { taskListId } = useParams();
+ const { fetchTaskList } = useAdminTask();
+
+ const [taskList, setTaskList] = useState(null);
+
+ useEffect(() => {
+ fetchTaskList(taskListId).then((data) => {
+ if (!data) return;
+ setTaskList(data);
+ });
+ }, [taskListId]);
+
+ if (!taskList) return (
+
+
+
+
+
+
+ );
+
+ const groups = taskList.groups ?? [];
+ const tasks = taskList.tasks ?? [];
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
{taskList.name}
+ {taskList.description && (
+
+ {taskList.description}
+
+ )}
+
+
+
+
+
+ {/* Main content — plain div, not Card, to avoid overflow:hidden clipping accordion */}
+
+
+
+ {/* Assigned Groups */}
+
+
+
+ Assigned Groups
+
+ {groups.length > 0 ? (
+
+ {groups.map((g) => (
+
+
+ {g.name ?? g.group_id}
+
+ ))}
+
+ ) : (
+
No groups assigned.
+ )}
+
+
+ {/* Tasks */}
+
+
+
+ Tasks
+ {tasks.length > 0 && (
+
+ {tasks.length} task{tasks.length !== 1 ? 's' : ''}
+
+ )}
+
+
+ {tasks.length > 0 ? (
+
+ {tasks.map((task, index) => (
+
+
+
+
+ {index + 1}.
+
+
+ {task.name ?? `Task ${index + 1}`}
+
+ {task.requirements?.length > 0 && (
+
+ {task.requirements.length} Requirement{task.requirements.length !== 1 ? 's' : ''}
+
+ )}
+
+
+
+
+
+
+ {task.description && (
+
+ )}
+
+ {task.deadline && (
+
+
+
+ Deadline:{' '}
+
+ {new Date(task.deadline).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })}
+
+
+
+ )}
+
+
+
+
+
+
+ ))}
+
+ ) : (
+
No tasks in this list yet.
+ )}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/modules/admin/pages/task_list/task/ArchiveTask.jsx b/src/modules/admin/pages/task_list/task/ArchiveTask.jsx
new file mode 100644
index 0000000..5b01144
--- /dev/null
+++ b/src/modules/admin/pages/task_list/task/ArchiveTask.jsx
@@ -0,0 +1,27 @@
+import { House } from "lucide-react";
+import { useParams } from "react-router-dom";
+import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
+import ArchivedTaskTable from "@/modules/admin/components/task/ArchiveTaskTable";
+
+export default function ArchivedTask() {
+ const { taskListId } = useParams();
+ const items = [
+ { label: "Home", icon: , to: "/admin" },
+ { label: "Task List", to: "/admin/taskList" },
+ { label: "Tasks", to: `/admin/taskList/${taskListId}/tasks` },
+ { label: "Archived" },
+ ];
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/modules/admin/pages/task_list/task/CreateTask.jsx b/src/modules/admin/pages/task_list/task/CreateTask.jsx
new file mode 100644
index 0000000..583fe52
--- /dev/null
+++ b/src/modules/admin/pages/task_list/task/CreateTask.jsx
@@ -0,0 +1,128 @@
+import { useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import { useAdminTask } from '@/contexts/AdminTaskContext';
+
+import RequirementBuilder from './RequirementBuilder';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Separator } from '@/components/ui/separator';
+import { ArrowLeft } from 'lucide-react';
+import DeadlinePicker from '@/components/generic/DeadlinePicker';
+
+export default function CreateTask() {
+ const navigate = useNavigate();
+ const { taskListId } = useParams();
+ const { createTask, loading } = useAdminTask();
+
+ const [form, setForm] = useState({
+ name: '',
+ description: '',
+ deadline: '',
+ requirements: [],
+ });
+ const [errors, setErrors] = useState({});
+
+ const validate = () => {
+ const e = {};
+ if (!form.name.trim()) e.name = 'Task name is required.';
+ setErrors(e);
+ return Object.keys(e).length === 0;
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!validate()) return;
+
+ const created = await createTask(taskListId, {
+ name: form.name.trim(),
+ description: form.description.trim() || null,
+ deadline: form.deadline || null,
+ requirements: form.requirements,
+ });
+
+ if (created) navigate(`/admin/tasks/${taskListId}/tasks/${created.task_id}`);
+ };
+
+ return (
+
+
+
+
Create Task
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/modules/admin/pages/task_list/task/EditTask.jsx b/src/modules/admin/pages/task_list/task/EditTask.jsx
new file mode 100644
index 0000000..062c86b
--- /dev/null
+++ b/src/modules/admin/pages/task_list/task/EditTask.jsx
@@ -0,0 +1,166 @@
+import { useEffect, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import { useAdminTask } from '@/contexts/AdminTaskContext';
+
+import RequirementBuilder from './RequirementBuilder';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Skeleton } from '@/components/ui/skeleton';
+import { ArrowLeft } from 'lucide-react';
+
+const STATUS_OPTIONS = [
+ { value: 'pending', label: 'Pending' },
+ { value: 'in_progress', label: 'In Progress' },
+ { value: 'completed', label: 'Completed' },
+ { value: 'overdue', label: 'Overdue' },
+];
+
+export default function EditTask() {
+ const navigate = useNavigate();
+ const { taskListId, taskId } = useParams();
+ const { fetchTask, updateTask, loading } = useAdminTask();
+
+ const [form, setForm] = useState(null);
+ const [errors, setErrors] = useState({});
+
+ useEffect(() => {
+ fetchTask(taskListId, taskId).then((data) => {
+ if (!data) return;
+ setForm({
+ name: data.name ?? '',
+ description: data.description ?? '',
+ deadline: data.deadline
+ ? new Date(data.deadline).toISOString().slice(0, 16)
+ : '',
+ status: data.status ?? 'pending',
+ requirements: data.requirements ?? [],
+ });
+ });
+ }, [taskListId, taskId]);
+
+ const validate = () => {
+ const e = {};
+ if (!form?.name?.trim()) e.name = 'Task name is required.';
+ setErrors(e);
+ return Object.keys(e).length === 0;
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!validate()) return;
+
+ const updated = await updateTask(taskListId, taskId, {
+ name: form.name.trim(),
+ description: form.description.trim() || null,
+ deadline: form.deadline || null,
+ status: form.status,
+ requirements: form.requirements,
+ });
+
+ if (updated) navigate(`/admin/taskList/${taskListId}/tasks/${taskId}`);
+ };
+
+ if (!form) return (
+
+
+
+
+
+ );
+
+ return (
+
+
+ {/** /admin/taskList/${taskListId}/${taskId}/tasks */}
+
+
Edit Task
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/modules/admin/pages/task_list/task/RequirementBuilder.jsx b/src/modules/admin/pages/task_list/task/RequirementBuilder.jsx
new file mode 100644
index 0000000..71693b3
--- /dev/null
+++ b/src/modules/admin/pages/task_list/task/RequirementBuilder.jsx
@@ -0,0 +1,282 @@
+import { useState } from 'react';
+import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Card, CardContent } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+
+// ─── Requirement type config ──────────────────────────────────────────────────
+const REQUIREMENT_TYPES = [
+ { value: 'visit_link', label: 'Visit a Link', icon: Link, category: 'Action' },
+ { value: 'upload_file', label: 'Upload a File', icon: Upload, category: 'Action' },
+ { value: 'read_course', label: 'Read a Course', icon: BookOpen, category: 'Content' },
+ { value: 'read_unit', label: 'Read a Unit', icon: Layers, category: 'Content' },
+ { value: 'read_lesson', label: 'Read a Lesson', icon: FileText, category: 'Content' },
+];
+
+const TYPE_MAP = Object.fromEntries(REQUIREMENT_TYPES.map((t) => [t.value, t]));
+
+const FILE_TYPE_OPTIONS = [
+ { value: 'pdf', label: 'PDF' },
+ { value: 'docx', label: 'DOCX' },
+ { value: 'xlsx', label: 'XLSX' },
+ { value: 'png', label: 'PNG' },
+ { value: 'jpg', label: 'JPG' },
+ { value: 'mp4', label: 'MP4' },
+ { value: 'zip', label: 'ZIP' },
+];
+
+// ─── Empty requirement factory ────────────────────────────────────────────────
+function createRequirement(type = 'visit_link') {
+ return {
+ _key: crypto.randomUUID(),
+ type,
+ // visit_link
+ link_url: '',
+ link_label: '',
+ // upload_file
+ allowed_file_types: [],
+ max_file_count: 1,
+ // read_*
+ reference_id: '',
+ reference_label: '',
+ };
+}
+
+// ─── RequirementBuilder ───────────────────────────────────────────────────────
+export default function RequirementBuilder({ value = [], onChange, courses = [], units = [], lessons = [] }) {
+ const [items, setItems] = useState(
+ value.length > 0
+ ? value.map((r) => ({ _key: crypto.randomUUID(), ...r }))
+ : []
+ );
+
+ const emit = (next) => {
+ setItems(next);
+ // strip _key before calling onChange
+ onChange?.(next.map(({ _key, ...r }) => r));
+ };
+
+ const addItem = () => emit([...items, createRequirement('visit_link')]);
+
+ const removeItem = (key) => emit(items.filter((i) => i._key !== key));
+
+ const updateItem = (key, patch) =>
+ emit(items.map((i) => (i._key === key ? { ...i, ...patch } : i)));
+
+ const toggleFileType = (key, ft) => {
+ const item = items.find((i) => i._key === key);
+ if (!item) return;
+ const current = item.allowed_file_types ?? [];
+ const next = current.includes(ft)
+ ? current.filter((t) => t !== ft)
+ : [...current, ft];
+ updateItem(key, { allowed_file_types: next });
+ };
+
+ return (
+
+ {items.length === 0 && (
+
+ No requirements added. Click "Add Requirement" to start.
+
+ )}
+
+ {items.map((item, idx) => {
+ const typeDef = TYPE_MAP[item.type];
+ const Icon = typeDef?.icon ?? Link;
+
+ return (
+
+
+ {/* Header row */}
+
+
+
+
+ {idx + 1}
+
+
+ {/* Type selector */}
+
+
+
+
+
+ {/* ── visit_link fields ── */}
+ {item.type === 'visit_link' && (
+
+ )}
+
+ {/* ── upload_file fields ── */}
+ {item.type === 'upload_file' && (
+
+
+
+
+ {FILE_TYPE_OPTIONS.map((ft) => (
+ toggleFileType(item._key, ft.value)}
+ >
+ {ft.label}
+
+ ))}
+
+
+
+
+ updateItem(item._key, { max_file_count: parseInt(e.target.value) || 1 })}
+ className="h-8 text-sm"
+ />
+
+
+ )}
+
+ {/* ── read_course / read_unit / read_lesson fields ── */}
+ {['read_course', 'read_unit', 'read_lesson'].includes(item.type) && (
+
+
+
+ {/* Reference selector */}
+ {item.type === 'read_course' && (
+
+ )}
+
+ {item.type === 'read_unit' && (
+
+ )}
+
+ {item.type === 'read_lesson' && (
+
+ )}
+
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/modules/admin/pages/task_list/task/Tasks.jsx b/src/modules/admin/pages/task_list/task/Tasks.jsx
new file mode 100644
index 0000000..a0676d4
--- /dev/null
+++ b/src/modules/admin/pages/task_list/task/Tasks.jsx
@@ -0,0 +1,304 @@
+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 { 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 } 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 { buildToolbarActions } from '@/modules/admin/config/task_list/task/toolbar.config';
+import { buildSelectionActions } from '@/modules/admin/config/task_list/task/selection.config';
+import { buildRowActions } from '@/modules/admin/config/task_list/task/rowActions.config';
+
+export default function Tasks() {
+ const navigate = useNavigate();
+ const { taskListId } = useParams();
+
+ const {
+ taskList, tasks, attributes, pagination, loading,
+ fetchTaskList, fetchTasks, fetchArchivedTasks,
+ archiveTask, restoreTask,
+ bulkArchiveTasks, bulkRestoreTasks,
+ } = useAdminTask();
+
+ 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 [showGroupsDialog, setShowGroupsDialog] = useState(false);
+
+ const tableRefsRef = useRef({
+ getFilters: () => [], getSort: () => [], resetSelection: () => { }, tableInstance: null,
+ });
+
+ useEffect(() => {
+ fetchTaskList(taskListId);
+ fetchTasks(taskListId, { page: 1, limit: 10 });
+ }, [taskListId]);
+
+ const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
+
+ const handleFetch = useCallback((params) => {
+ const fetcher = showArchived ? fetchArchivedTasks : fetchTasks;
+ return fetcher(taskListId, params);
+ }, [fetchTasks, fetchArchivedTasks, taskListId, showArchived]);
+
+ const handleToggleArchived = () => {
+ const next = !showArchived;
+ setShowArchived(next);
+ const fetcher = next ? fetchArchivedTasks : fetchTasks;
+ fetcher(taskListId, {
+ page: 1,
+ limit: pagination?.limit ?? 10,
+ filters: tableRefsRef.current.getFilters(),
+ sort: tableRefsRef.current.getSort(),
+ });
+ };
+
+ const afterMutation = () => {
+ tableRefsRef.current.resetSelection?.();
+ const fetcher = showArchived ? fetchArchivedTasks : fetchTasks;
+ fetcher(taskListId, {
+ page: 1,
+ limit: pagination?.limit ?? 10,
+ filters: tableRefsRef.current.getFilters(),
+ sort: tableRefsRef.current.getSort(),
+ });
+ };
+
+ const rowActions = useMemo(() => buildRowActions({
+ navigate,
+ onArchive: (row) => setArchiveTarget(row),
+ onRestore: (row) => setRestoreTarget(row),
+ showArchived,
+ }), [navigate, showArchived]);
+
+ const toolbarActions = buildToolbarActions({
+ fetchTasks, fetchArchivedTasks, taskListId, pagination, navigate,
+ showArchived, onToggleArchived: handleToggleArchived,
+ getFilters: () => tableRefsRef.current.getFilters(),
+ getSort: () => tableRefsRef.current.getSort(),
+ });
+
+ const selectionActions = buildSelectionActions({
+ showArchived,
+ onBulkArchive: (ids) => setBulkArchiveIds(ids),
+ onBulkRestore: (ids) => setBulkRestoreIds(ids),
+ getTableInstance: () => tableRefsRef.current.tableInstance,
+ });
+
+ 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: , to: '/admin' },
+ { label: 'Task List', to: '/admin/taskList' },
+ { label: 'View Tasks' },
+ ];
+
+ return (
+
+
+ {/* ── Breadcrumb ────────────────────────────────────────────────── */}
+
+
+ {/* ── Detail card ───────────────────────────────────────────────── */}
+
+
+
+ {/* Name */}
+ {taskList
+ ?
{taskList.name}
+ :
+ }
+ {/* Description */}
+ {taskList
+ ?
{taskList.description ?? '—'}
+ :
+ }
+
+
+
+ {/* Stats grid */}
+
+
+ {/* Total tasks */}
+
+
+ Tasks
+
+
+
+ {taskList ? totalTasks : }
+
+
+
+ {/* Assigned groups */}
+
+
+ Groups
+
+ {taskList ? (
+ taskList.group_count > 0 ? (
+
+
+
+ {taskList.group_count}
+
+ {hasOverflow && (
+
+ )}
+
+ ) : 0
+ ) : (
+
+ )}
+
+
+ {/* Created */}
+
+
+ Created
+
+
+ {taskList ? formattedCreated : }
+
+
+
+ {/* Last updated */}
+
+
+ Last Updated
+
+
+ {taskList ? formattedUpdated : }
+
+
+
+
+
+
+ {/* ── Tasks DataTable ───────────────────────────────────────────── */}
+
+
+ {/* ── All Groups Dialog ─────────────────────────────────────────── */}
+
+
+ {/* ── Single archive ────────────────────────────────────────────── */}
+
!v && setArchiveTarget(null)}
+ entity={archiveTarget}
+ entityLabel="Task"
+ getName={(r) => r?.name}
+ onArchive={async (r) => {
+ const ok = await archiveTask(taskListId, r?.task_id);
+ if (ok) { setArchiveTarget(null); afterMutation(); }
+ }}
+ loading={loading}
+ />
+
+ {/* ── Single restore ────────────────────────────────────────────── */}
+ !v && setRestoreTarget(null)}
+ entity={restoreTarget}
+ entityLabel="Task"
+ getName={(r) => r?.name}
+ onRestore={async (r) => {
+ const ok = await restoreTask(taskListId, r?.task_id);
+ if (ok) { setRestoreTarget(null); afterMutation(); }
+ }}
+ loading={loading}
+ />
+
+ {/* ── Bulk archive ──────────────────────────────────────────────── */}
+ !v && setBulkArchiveIds(null)}
+ entity={bulkArchiveIds}
+ entityLabel={`${bulkArchiveIds?.length ?? 0} Task(s)`}
+ getName={() => `${bulkArchiveIds?.length ?? 0} task(s)`}
+ onArchive={async () => {
+ const ok = await bulkArchiveTasks(taskListId, bulkArchiveIds);
+ if (ok) { setBulkArchiveIds(null); afterMutation(); }
+ }}
+ loading={loading}
+ />
+
+ {/* ── Bulk restore ──────────────────────────────────────────────── */}
+ !v && setBulkRestoreIds(null)}
+ entity={bulkRestoreIds}
+ entityLabel={`${bulkRestoreIds?.length ?? 0} Task(s)`}
+ getName={() => `${bulkRestoreIds?.length ?? 0} task(s)`}
+ onRestore={async () => {
+ const ok = await bulkRestoreTasks(taskListId, bulkRestoreIds);
+ if (ok) { setBulkRestoreIds(null); afterMutation(); }
+ }}
+ loading={loading}
+ />
+
+
+ );
+}
\ 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
new file mode 100644
index 0000000..5aaa886
--- /dev/null
+++ b/src/modules/admin/pages/task_list/task/ViewTask.jsx
@@ -0,0 +1,242 @@
+import { useEffect, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import { useAdminTask } from '@/contexts/AdminTaskContext';
+
+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 {
+ ArrowLeft, Pencil, FileText, CalendarClock,
+ Link2, Upload, BookOpen, BookMarked, FileCheck2,
+ Info, GitPullRequest, Tag, Globe, Copy, File, Bookmark,
+} from 'lucide-react';
+
+// ─── Same config as ViewTaskList — label, icon only, all styling via shadcn tokens
+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 ────────────────────────────────────────────────────────
+function MetaRow({ icon: Icon, label, children }) {
+ return (
+
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
+
+// ─── Requirement card ─────────────────────────────────────────────────────────
+function RequirementCard({ req }) {
+ const cfg = REQUIREMENT_CONFIG[req.type] ?? {
+ label: req.type, badgeLabel: req.type, Icon: FileText,
+ };
+ const { Icon } = cfg;
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+ {cfg.label}
+
+
+ {cfg.badgeLabel}
+
+
+
+ {/* Rows */}
+ {req.type === 'visit_link' && (
+ <>
+ {req.link_label && (
+
+ {req.link_label}
+
+ )}
+ {req.link_url && (
+
+
+ {req.link_url}
+
+
+ )}
+ >
+ )}
+
+ {req.type === 'upload_file' && (
+ <>
+ {req.max_file_count != null && (
+
+ {req.max_file_count}
+
+ )}
+ {req.allowed_file_types?.length > 0 && (
+
+ {req.allowed_file_types.join(', ')}
+
+ )}
+ >
+ )}
+
+ {['read_course', 'read_unit', 'read_lesson'].includes(req.type) && req.reference_label && (
+
+ {req.reference_label}
+
+ )}
+
+ );
+}
+
+// ─── Requirements section ─────────────────────────────────────────────────────
+function TaskRequirementsSection({ requirements = [] }) {
+ const sorted = [...requirements].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
+ return (
+
+ {sorted.length > 0 ? (
+ sorted.map((req) => (
+
+ ))
+ ) : (
+
No other requirements.
+ )}
+
+ );
+}
+
+// ─── Page ─────────────────────────────────────────────────────────────────────
+export default function ViewTask() {
+ const navigate = useNavigate();
+ const { taskListId, taskId } = useParams();
+ const { fetchTask } = useAdminTask();
+
+ const [task, setTask] = useState(null);
+
+ useEffect(() => {
+ fetchTask(taskListId, taskId).then((data) => {
+ if (!data) return;
+ setTask(data);
+ });
+ }, [taskListId, taskId]);
+
+ if (!task) return (
+
+
+
+
+
+
+ );
+
+ const requirements = task.requirements ?? [];
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
{task.name}
+
Task details
+
+
+
+
+
+ {/* Main content — plain div avoids Card overflow:hidden clipping */}
+
+
+
+ {/* Description */}
+ {task.description ? (
+
+ ) : (
+
No description provided.
+ )}
+
+ {/* Deadline */}
+ {task.deadline && (
+ <>
+
+
+
+
+ Deadline:{' '}
+
+ {new Date(task.deadline).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })}
+
+
+
+ >
+ )}
+
+ {/* Status */}
+ {task.status && (
+ <>
+
+
+
+ Status
+
+
+ {task.status}
+
+
+ >
+ )}
+
+
+
+ {/* Requirements */}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/modules/admin/routes/AdminRoutes.jsx b/src/modules/admin/routes/AdminRoutes.jsx
index a8ad595..e1679f1 100644
--- a/src/modules/admin/routes/AdminRoutes.jsx
+++ b/src/modules/admin/routes/AdminRoutes.jsx
@@ -25,6 +25,7 @@ import ViewVideoAsset from "../pages/assets/ViewVideoAsset";
import ViewDocumentAsset from "../pages/assets/ViewDocumentAsset";
import AssetList from '../pages/assets/AssetList'
import EditAsset from '../pages/assets/EditAsset'
+
import CourseList from '../pages/courses/CourseList'
import CreateCourse from '../pages/courses/CreateCourse'
import EditCourse from '../pages/courses/EditCourse'
@@ -37,6 +38,18 @@ import LessonPageBuilder from '../pages/courses/LessonPageBuilder'
import EditUnit from '../pages/courses/EditUnit'
import EditLesson from '../pages/courses/EditLesson'
+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'
+
+import CreateTask from '../pages/task_list/task/CreateTask'
+import EditTask from '../pages/task_list/task/EditTask'
+import ViewTask from '../pages/task_list/task/ViewTask'
+import ArchivedTask from '../pages/task_list/task/ArchiveTask'
+
export const AdminRoutes = {
element: ,
children: [
@@ -106,6 +119,30 @@ export const AdminRoutes = {
]
},
+ // Task
+ {
+ path: 'taskList',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'create', element: },
+ { path: 'archived', element: },
+ { path: ':taskListId/view', element: },
+ { path: ':taskListId/edit', element: },
+ {
+ path: ':taskListId/tasks',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'create', element: },
+ { path: 'archived', element: },
+ { path: ':taskId/view', element: },
+ { path: ':taskId/edit', element: },
+ ]
+ }
+ ]
+ }
+
// Add here
]
},