mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Merged
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAdminTask } from "@/contexts/AdminTaskContext";
|
||||
|
||||
import DataTable from "@/components/generic/Table/DataTable";
|
||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
||||
|
||||
import { buildDataColumns, columnPinning } from "@/modules/admin/config/task_list/archive/columns.config";
|
||||
import { buildToolbarActions } from "@/modules/admin/config/task_list/archive/toolbar.config";
|
||||
import { buildSelectionActions } from "@/modules/admin/config/task_list/archive/selection.config";
|
||||
import { buildRowActions } from "@/modules/admin/config/task_list/archive/rowActions.config";
|
||||
|
||||
import { getTimestamp } from "@/utils/timestamp.util";
|
||||
|
||||
export default function ArchiveTaskListTable() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
taskLists, attributes, pagination, loading,
|
||||
fetchArchivedTaskLists,
|
||||
restoreTaskList,
|
||||
bulkRestoreTaskLists,
|
||||
} = useAdminTask();
|
||||
|
||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||
const [restoreIds, setRestoreIds] = useState(null);
|
||||
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [],
|
||||
getSort: () => [],
|
||||
resetSelection: () => { },
|
||||
tableInstance: null,
|
||||
});
|
||||
|
||||
const handleRefsReady = (refs) => {
|
||||
tableRefsRef.current = refs;
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
setRestoreTarget(null);
|
||||
setRestoreIds(null);
|
||||
tableRefsRef.current.resetSelection?.();
|
||||
fetchArchivedTaskLists({
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: tableRefsRef.current.getFilters(),
|
||||
sort: tableRefsRef.current.getSort(),
|
||||
});
|
||||
};
|
||||
|
||||
const exportConfig = {
|
||||
allData: taskLists,
|
||||
attributes,
|
||||
filename: `${getTimestamp()}_ArchivedTaskLists`,
|
||||
sheetName: "Archived Task Lists",
|
||||
};
|
||||
|
||||
const rowActions = buildRowActions({
|
||||
navigate,
|
||||
onRestore: (row) => setRestoreTarget(row),
|
||||
});
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
fetchArchivedTaskLists,
|
||||
pagination,
|
||||
exportConfig,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
onRestoreMany: (ids) => setRestoreIds(ids),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(attributes, rowActions),
|
||||
[attributes, rowActions]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
title="Archived Task Lists"
|
||||
data={taskLists}
|
||||
columns={columns}
|
||||
attributes={attributes}
|
||||
pagination={pagination}
|
||||
loading={loading}
|
||||
onFetch={fetchArchivedTaskLists}
|
||||
onFetchFilterData={() => []}
|
||||
onRefsReady={handleRefsReady}
|
||||
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||
<FilterSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
column={column}
|
||||
attr={attr}
|
||||
data={data}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
columnPinning={columnPinning}
|
||||
toolbarActions={toolbarActions}
|
||||
selectionActions={selectionActions}
|
||||
recordLabel="archived task list"
|
||||
emptyMessage="No archived task lists found."
|
||||
/>
|
||||
|
||||
{/* Single restore */}
|
||||
<RestoreDialog
|
||||
open={!!restoreTarget}
|
||||
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||
entity={restoreTarget}
|
||||
entityLabel="Task List"
|
||||
getName={(r) => r?.name}
|
||||
onRestore={(r) => restoreTaskList(r?.task_list_id)}
|
||||
loading={loading}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
{/* Bulk restore */}
|
||||
<RestoreDialog
|
||||
open={!!restoreIds}
|
||||
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||
ids={restoreIds ?? []}
|
||||
entityLabel="Task List"
|
||||
onRestore={(ids) => bulkRestoreTaskLists(ids)}
|
||||
loading={loading}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useMemo, useRef, useState, useCallback } 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 { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
||||
|
||||
import { buildDataColumns, columnPinning } from "@/modules/admin/config/task_list/task/archive/columns.config";
|
||||
import { buildToolbarActions } from "@/modules/admin/config/task_list/task/archive/toolbar.config";
|
||||
import { buildSelectionActions } from "@/modules/admin/config/task_list/task/archive/selection.config";
|
||||
import { buildRowActions } from "@/modules/admin/config/task_list/task/archive/rowActions.config";
|
||||
|
||||
import { getTimestamp } from "@/utils/timestamp.util";
|
||||
|
||||
export default function ArchivedTaskTable() {
|
||||
const navigate = useNavigate();
|
||||
const { taskListId } = useParams();
|
||||
|
||||
const {
|
||||
tasks, attributes, pagination, loading,
|
||||
fetchArchivedTasks,
|
||||
restoreTask,
|
||||
bulkRestoreTasks,
|
||||
} = useAdminTask();
|
||||
|
||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||
const [restoreIds, setRestoreIds] = useState(null);
|
||||
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [],
|
||||
getSort: () => [],
|
||||
resetSelection: () => {},
|
||||
tableInstance: null,
|
||||
});
|
||||
|
||||
const handleRefsReady = (refs) => {
|
||||
tableRefsRef.current = refs;
|
||||
};
|
||||
|
||||
// ── Stable fetch callback — won't change on every render ─────────────────
|
||||
const handleFetch = useCallback(
|
||||
(params) => fetchArchivedTasks(taskListId, params),
|
||||
[taskListId]
|
||||
);
|
||||
|
||||
const handleSuccess = () => {
|
||||
setRestoreTarget(null);
|
||||
setRestoreIds(null);
|
||||
tableRefsRef.current.resetSelection?.();
|
||||
fetchArchivedTasks(taskListId, {
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: tableRefsRef.current.getFilters(),
|
||||
sort: tableRefsRef.current.getSort(),
|
||||
});
|
||||
};
|
||||
|
||||
const exportConfig = useMemo(() => ({
|
||||
allData: tasks,
|
||||
attributes,
|
||||
filename: `${getTimestamp()}_ArchivedTasks`,
|
||||
sheetName: "Archived Tasks",
|
||||
}), [tasks, attributes]);
|
||||
|
||||
const rowActions = useMemo(() => buildRowActions({
|
||||
navigate,
|
||||
onRestore: (row) => setRestoreTarget(row),
|
||||
}), [navigate]);
|
||||
|
||||
const toolbarActions = useMemo(() => buildToolbarActions({
|
||||
taskListId,
|
||||
navigate,
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
fetchArchivedTasks: handleFetch,
|
||||
pagination,
|
||||
exportConfig,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
}), [taskListId, navigate, handleFetch, pagination, exportConfig]);
|
||||
|
||||
const selectionActions = useMemo(() => buildSelectionActions({
|
||||
exportConfig,
|
||||
onRestoreMany: (ids) => setRestoreIds(ids),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
}), [exportConfig]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(attributes, rowActions),
|
||||
[attributes, rowActions]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
title="Archived Tasks"
|
||||
data={tasks}
|
||||
columns={columns}
|
||||
attributes={attributes}
|
||||
pagination={pagination}
|
||||
loading={loading}
|
||||
onFetch={handleFetch}
|
||||
onFetchFilterData={() => []}
|
||||
onRefsReady={handleRefsReady}
|
||||
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||
<FilterSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
column={column}
|
||||
attr={attr}
|
||||
data={data}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
columnPinning={columnPinning}
|
||||
toolbarActions={toolbarActions}
|
||||
selectionActions={selectionActions}
|
||||
recordLabel="archived task"
|
||||
emptyMessage="No archived tasks found."
|
||||
/>
|
||||
|
||||
{/* Single restore */}
|
||||
<RestoreDialog
|
||||
open={!!restoreTarget}
|
||||
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||
entity={restoreTarget}
|
||||
entityLabel="Task"
|
||||
getName={(r) => r?.name}
|
||||
onRestore={(r) => restoreTask(taskListId, r?.task_id)}
|
||||
loading={loading}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
{/* Bulk restore */}
|
||||
<RestoreDialog
|
||||
open={!!restoreIds}
|
||||
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||
ids={restoreIds ?? []}
|
||||
entityLabel="Task"
|
||||
onRestore={(ids) => bulkRestoreTasks(taskListId, ids)}
|
||||
loading={loading}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useMemo, useRef, useState, useCallback } from "react";
|
||||
import { useNavigate } 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 { buildDataColumns, columnPinning } from "../../config/task_list/columns.config";
|
||||
import { buildToolbarActions } from "../../config/task_list/toolbar.config";
|
||||
import { buildSelectionActions } from "../../config/task_list/selection.config";
|
||||
import { buildRowActions } from "../../config/task_list/rowActions.config";
|
||||
|
||||
import { getTimestamp } from "@/utils/timestamp.util";
|
||||
|
||||
export default function TaskListTable() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
taskLists, attributes, pagination, loading,
|
||||
fetchTaskLists, fetchArchivedTaskLists,
|
||||
archiveTaskList, restoreTaskList,
|
||||
bulkArchiveTaskLists, bulkRestoreTaskLists,
|
||||
} = useAdminTask();
|
||||
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||
const [archiveIds, setArchiveIds] = useState(null);
|
||||
const [restoreIds, setRestoreIds] = useState(null);
|
||||
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [],
|
||||
getSort: () => [],
|
||||
resetSelection: () => { },
|
||||
tableInstance: null,
|
||||
});
|
||||
|
||||
const handleRefsReady = (refs) => {
|
||||
tableRefsRef.current = refs;
|
||||
};
|
||||
|
||||
// ── Toggle archived view ──────────────────────────────────────────────────
|
||||
const handleToggleArchived = useCallback(() => {
|
||||
const next = !showArchived;
|
||||
setShowArchived(next);
|
||||
fetchTaskLists({
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: tableRefsRef.current.getFilters(),
|
||||
sort: tableRefsRef.current.getSort(),
|
||||
archived: next,
|
||||
});
|
||||
}, [showArchived, pagination, fetchTaskLists, fetchArchivedTaskLists]);
|
||||
|
||||
// ── Refetch helper ────────────────────────────────────────────────────────
|
||||
const handleSuccess = () => {
|
||||
setArchiveTarget(null);
|
||||
setRestoreTarget(null);
|
||||
setArchiveIds(null);
|
||||
setRestoreIds(null);
|
||||
tableRefsRef.current.resetSelection?.();
|
||||
const fetcher = showArchived ? fetchArchivedTaskLists : fetchTaskLists;
|
||||
fetcher({
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: tableRefsRef.current.getFilters(),
|
||||
sort: tableRefsRef.current.getSort(),
|
||||
});
|
||||
};
|
||||
|
||||
// ── Export config ─────────────────────────────────────────────────────────
|
||||
const exportConfig = {
|
||||
allData: taskLists,
|
||||
attributes,
|
||||
filename: `${getTimestamp()}_TaskLists`,
|
||||
sheetName: "Task Lists",
|
||||
};
|
||||
|
||||
// ── Row actions ───────────────────────────────────────────────────────────
|
||||
const rowActions = buildRowActions({
|
||||
navigate,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onRestore: (row) => setRestoreTarget(row),
|
||||
showArchived,
|
||||
});
|
||||
|
||||
// ── Toolbar ───────────────────────────────────────────────────────────────
|
||||
const toolbarActions = buildToolbarActions({
|
||||
fetchTaskLists, fetchArchivedTaskLists, pagination, navigate,
|
||||
showArchived,
|
||||
onToggleArchived: handleToggleArchived,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
// ── Selection ─────────────────────────────────────────────────────────────
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
showArchived,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onArchiveMany: (ids) => setArchiveIds(ids),
|
||||
onRestore: (row) => setRestoreTarget(row),
|
||||
onRestoreMany: (ids) => setRestoreIds(ids),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
// ── Columns ───────────────────────────────────────────────────────────────
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(attributes, rowActions),
|
||||
[attributes, rowActions]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
title="Task Lists"
|
||||
data={taskLists}
|
||||
columns={columns}
|
||||
attributes={attributes}
|
||||
pagination={pagination}
|
||||
loading={loading}
|
||||
onFetch={fetchTaskLists}
|
||||
onFetchFilterData={() => []}
|
||||
onRefsReady={handleRefsReady}
|
||||
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||
<FilterSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
column={column}
|
||||
attr={attr}
|
||||
data={data}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
columnPinning={columnPinning}
|
||||
toolbarActions={toolbarActions}
|
||||
selectionActions={selectionActions}
|
||||
recordLabel="task list"
|
||||
emptyMessage="No task lists found."
|
||||
/>
|
||||
|
||||
{/* Single archive */}
|
||||
<ArchiveDialog
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||
entity={archiveTarget}
|
||||
entityLabel="Task List"
|
||||
getName={(r) => r?.name}
|
||||
onArchive={(r) => archiveTaskList(r?.task_list_id)}
|
||||
loading={loading}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
{/* Single restore */}
|
||||
<RestoreDialog
|
||||
open={!!restoreTarget}
|
||||
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||
entity={restoreTarget}
|
||||
entityLabel="Task List"
|
||||
getName={(r) => r?.name}
|
||||
onRestore={(r) => restoreTaskList(r?.task_list_id)}
|
||||
loading={loading}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
{/* Bulk archive */}
|
||||
<ArchiveDialog
|
||||
open={!!archiveIds}
|
||||
onOpenChange={(v) => !v && setArchiveIds(null)}
|
||||
ids={archiveIds ?? []}
|
||||
entityLabel="Task List"
|
||||
onArchive={(ids) => bulkArchiveTaskLists(ids)}
|
||||
loading={loading}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
{/* Bulk restore */}
|
||||
<RestoreDialog
|
||||
open={!!restoreIds}
|
||||
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||
ids={restoreIds ?? []}
|
||||
entityLabel="Task List"
|
||||
onRestore={(ids) => bulkRestoreTaskLists(ids)}
|
||||
loading={loading}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
// <div className="flex items-center gap-1.5">
|
||||
// <Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
// <Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||
// {count} {count === 1 ? "member" : "members"}
|
||||
// </Badge>
|
||||
// </div>
|
||||
// );
|
||||
// },
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 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" }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { RotateCcw, Eye } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ navigate, onRestore }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
label: "View",
|
||||
icon: <Eye className="size-4" />,
|
||||
onClick: (row) => navigate(`/admin/taskList/${row.task_list_id}/view`),
|
||||
},
|
||||
{
|
||||
key: "restore",
|
||||
label: "Restore",
|
||||
icon: <RotateCcw className="size-4" />,
|
||||
onClick: (row) => onRestore(row),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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: <Download className="size-4" />,
|
||||
onClick: () =>
|
||||
exportTableToExcel({
|
||||
...exportConfig,
|
||||
tableInstance: getTableInstance?.(),
|
||||
selectedOnly: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "bulk-restore",
|
||||
label: "Restore Selected",
|
||||
icon: <RotateCcw className="size-4" />,
|
||||
variant: "outline",
|
||||
onClick: (selectedRows) =>
|
||||
onRestoreMany(selectedRows.map((r) => r.task_list_id)),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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: <RefreshCw className="size-4" />,
|
||||
label: "Refresh",
|
||||
onClick: () =>
|
||||
fetchArchivedTaskLists({
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: getFilters?.() ?? [],
|
||||
sort: getSort?.() ?? [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
type: "button",
|
||||
icon: <Download className="size-4" />,
|
||||
label: "Export",
|
||||
onClick: (table) =>
|
||||
exportTableToExcel({
|
||||
...exportConfig,
|
||||
tableInstance: table ?? getTableInstance?.(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 (
|
||||
// <div className="flex items-center gap-1.5">
|
||||
// <Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
// <Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||
// {count} {count === 1 ? "member" : "members"}
|
||||
// </Badge>
|
||||
// </div>
|
||||
// );
|
||||
// },
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 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" }),
|
||||
];
|
||||
}
|
||||
@@ -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: <Eye className="size-4" />,
|
||||
onClick: (row) => navigate(`${row.task_list_id}/view`),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit Info",
|
||||
icon: <Pencil className="size-4" />,
|
||||
onClick: (row) => navigate(`${row.task_list_id}/edit`),
|
||||
},
|
||||
{
|
||||
key: "view",
|
||||
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",
|
||||
className: "text-destructive focus:text-destructive",
|
||||
icon: <Archive className="size-4" />,
|
||||
onClick: (row) => onArchive(row),
|
||||
hidden: () => showArchived,
|
||||
separator: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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: <Download className="h-3.5 w-3.5" />,
|
||||
onClick: (rows, table) =>
|
||||
exportTableToExcel({
|
||||
...exportConfig,
|
||||
selectedRows: rows,
|
||||
tableInstance: table ?? getTableInstance?.(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: showArchived ? "restore-selected" : "archive-selected",
|
||||
label: showArchived ? "Restore" : "Archive",
|
||||
icon: showArchived ? (
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
),
|
||||
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);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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" }),
|
||||
];
|
||||
}
|
||||
@@ -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: <RotateCcw className="size-4" />,
|
||||
onClick: (row) => onRestore(row),
|
||||
hidden: (row) => row.is_active,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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: <Download className="h-3.5 w-3.5" />,
|
||||
onClick: (rows, table) =>
|
||||
exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table ?? getTableInstance() }),
|
||||
},
|
||||
{
|
||||
key: "archive-selected",
|
||||
label: "Archive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
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"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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: <RefreshCw className="size-4" />,
|
||||
label: "Refresh",
|
||||
onClick: () =>
|
||||
fetchTasks(taskId, {
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: getFilters?.() ?? [],
|
||||
sort: getSort?.() ?? [],
|
||||
archived: showArchived,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
type: "button",
|
||||
icon: <Download className="size-4" />,
|
||||
label: "Export",
|
||||
onClick: (table) =>
|
||||
exportTableToExcel({
|
||||
...exportConfig,
|
||||
tableInstance: table ?? getTableInstance?.(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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" }),
|
||||
];
|
||||
}
|
||||
@@ -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: <Eye className="size-4" />,
|
||||
onClick: (row) => navigate(`${row.task_id}/view`),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit Info",
|
||||
icon: <Pencil className="size-4" />,
|
||||
onClick: (row) => navigate(`${row.task_id}/edit`),
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
className: "text-destructive focus:text-destructive",
|
||||
icon: <Archive className="size-4" />,
|
||||
onClick: (row) => onArchive(row),
|
||||
hidden: () => showArchived,
|
||||
separator: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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: <Download className="h-3.5 w-3.5" />,
|
||||
onClick: (rows, table) =>
|
||||
exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table ?? getTableInstance() }),
|
||||
},
|
||||
{
|
||||
key: "archive-selected",
|
||||
label: "Archive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
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"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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: <RefreshCw className="size-4" />,
|
||||
label: "Refresh",
|
||||
onClick: () =>
|
||||
fetchTasks(taskId, {
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: getFilters?.() ?? [],
|
||||
sort: getSort?.() ?? [],
|
||||
archived: showArchived,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
type: "button",
|
||||
icon: <Download className="size-4" />,
|
||||
label: "Export",
|
||||
onClick: (table) =>
|
||||
exportTableToExcel({
|
||||
...exportConfig,
|
||||
tableInstance: table ?? getTableInstance?.(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "add-task",
|
||||
type: "button",
|
||||
icon: <Plus className="size-4" />,
|
||||
label: "Create Task",
|
||||
variant: "default",
|
||||
className: "text-primary-foreground",
|
||||
onClick: () => navigate(`/admin/taskList/${taskListId}/tasks/create`),
|
||||
},
|
||||
{
|
||||
key: "toggle-archived-task",
|
||||
type: "button",
|
||||
icon: <Archive className="size-4" />,
|
||||
label: showArchived ? "Active Tasks" : "Archived Tasks",
|
||||
variant: "secondary",
|
||||
className: "border border-border",
|
||||
// onClick: onToggleArchived,
|
||||
onClick: () => navigate(`/admin/taskList/${taskListId}/tasks/archived`),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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: <RefreshCw className="size-4" />,
|
||||
label: "Refresh",
|
||||
onClick: () =>
|
||||
fetchTasks(taskListId, {
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: getFilters?.() ?? [],
|
||||
sort: getSort?.() ?? [],
|
||||
archived: showArchived,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
type: "button",
|
||||
icon: <Download className="size-4" />,
|
||||
label: "Export",
|
||||
onClick: (table) =>
|
||||
exportTableToExcel({
|
||||
...exportConfig,
|
||||
tableInstance: table ?? getTableInstance?.(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "add-task",
|
||||
type: "button",
|
||||
icon: <Plus className="size-4" />,
|
||||
label: "Create Task List",
|
||||
variant: "default",
|
||||
className: "text-primary-foreground",
|
||||
onClick: () => navigate(`/admin/taskList/create`),
|
||||
},
|
||||
{
|
||||
key: "toggle-archived-task",
|
||||
type: "button",
|
||||
icon: <Archive className="size-4" />,
|
||||
label: showArchived ? "Active Task Lists" : "Archived Task List",
|
||||
variant: "secondary",
|
||||
className: "border border-border",
|
||||
onClick: () => navigate("/admin/taskList/archived"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Task List", to: "/admin/taskList" },
|
||||
{ label: "Archived" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<ArchiveTaskListTable />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="max-w-xl mx-auto py-8 px-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create Task List</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="e.g. Onboarding Tasks"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-xs text-destructive">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="Optional description"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Groups */}
|
||||
<div className="space-y-1">
|
||||
<Label>
|
||||
Assign to Groups
|
||||
<span className="ml-1.5 text-xs text-muted-foreground font-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</Label>
|
||||
<GroupMultiSelect
|
||||
value={selectedGroupIds}
|
||||
onChange={setSelectedGroupIds}
|
||||
disabled={loading}
|
||||
placeholder="Select groups to assign…"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Members of selected groups will be able to see and complete this task list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/admin/taskList')}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Creating…' : 'Create Task List'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="max-w-xl mx-auto py-8 px-4 space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto py-8 px-4">
|
||||
<div className="flex gap-2 items-center mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/admin/taskList')}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="space-y-1">
|
||||
<h1 className="font-medium">Edit Task List</h1>
|
||||
<p className="text-sm text-muted-foreground">Update task list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-xs text-destructive">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Groups */}
|
||||
<div className="space-y-1">
|
||||
<Label>
|
||||
Assigned Groups
|
||||
<span className="ml-1.5 text-xs text-muted-foreground font-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</Label>
|
||||
<GroupMultiSelect
|
||||
value={selectedGroupIds}
|
||||
onChange={setSelectedGroupIds}
|
||||
disabled={loading}
|
||||
placeholder="Select groups to assign…"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Members of selected groups will be able to see and complete this task list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/admin/taskList')}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !isDirty}
|
||||
>
|
||||
{loading ? 'Saving…' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Task List" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<TaskListTable />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 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={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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{['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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<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="max-w-2xl mx-auto py-8 px-4 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" />
|
||||
|
||||
</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>
|
||||
</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>
|
||||
|
||||
{/* Main content — plain div, not Card, to avoid overflow:hidden clipping accordion */}
|
||||
<div className="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">
|
||||
{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">
|
||||
{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 overflow-visible">
|
||||
<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">
|
||||
{new Date(task.deadline).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TaskRequirementsSection requirements={task.requirements} />
|
||||
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No tasks in this list yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Task List", to: "/admin/taskList" },
|
||||
{ label: "Tasks", to: `/admin/taskList/${taskListId}/tasks` },
|
||||
{ label: "Archived" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<ArchivedTaskTable />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="max-w-2xl mx-auto py-8 px-4 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/taskList/${taskListId}/tasks`)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">Create Task</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Task info */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Task Details</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="e.g. Complete orientation video"
|
||||
/>
|
||||
{errors.name && <p className="text-xs text-destructive">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="Optional task description"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Deadline — date popover + time input */}
|
||||
<div className="space-y-3">
|
||||
<Label>Deadline</Label>
|
||||
<DeadlinePicker
|
||||
value={form.deadline}
|
||||
onChange={(iso) => setForm({ ...form, deadline: iso })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Requirements */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Requirements</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Define what a user needs to do to complete this task.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RequirementBuilder
|
||||
value={form.requirements}
|
||||
onChange={(reqs) => setForm({ ...form, requirements: reqs })}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(`/admin/taskList/${taskListId}/tasks`)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Creating…' : 'Create Task'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="max-w-2xl mx-auto py-8 px-4 space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-8 px-4 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{/** /admin/taskList/${taskListId}/${taskId}/tasks */}
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">Edit Task</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Task Details</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
{errors.name && <p className="text-xs text-destructive">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="deadline">Deadline</Label>
|
||||
<Input
|
||||
id="deadline"
|
||||
type="datetime-local"
|
||||
value={form.deadline}
|
||||
onChange={(e) => setForm({ ...form, deadline: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Status</Label>
|
||||
<Select
|
||||
value={form.status}
|
||||
onValueChange={(v) => setForm({ ...form, status: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Requirements</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Changes here will replace existing requirements.</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RequirementBuilder
|
||||
value={form.requirements}
|
||||
onChange={(reqs) => setForm({ ...form, requirements: reqs })}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
{/** /admin/taskList/${taskListId}/${taskId}/tasks */}
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Saving…' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-6 border border-dashed rounded-lg">
|
||||
No requirements added. Click "Add Requirement" to start.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.map((item, idx) => {
|
||||
const typeDef = TYPE_MAP[item.type];
|
||||
const Icon = typeDef?.icon ?? Link;
|
||||
|
||||
return (
|
||||
<Card key={item._key} className="relative">
|
||||
<CardContent className="pt-4 pb-4 space-y-3">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<Badge variant="outline" className="text-xs gap-1 shrink-0">
|
||||
<Icon className="h-3 w-3" />
|
||||
{idx + 1}
|
||||
</Badge>
|
||||
|
||||
{/* Type selector */}
|
||||
<Select
|
||||
value={item.type}
|
||||
onValueChange={(v) => updateItem(item._key, { type: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REQUIREMENT_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<t.icon className="h-3.5 w-3.5" />
|
||||
{t.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
|
||||
onClick={() => removeItem(item._key)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── visit_link fields ── */}
|
||||
{item.type === 'visit_link' && (
|
||||
<div className="grid grid-cols-2 gap-3 pl-7">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">URL *</Label>
|
||||
<Input
|
||||
placeholder="https://example.com"
|
||||
value={item.link_url}
|
||||
onChange={(e) => updateItem(item._key, { link_url: e.target.value })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Label (optional)</Label>
|
||||
<Input
|
||||
placeholder="Link description"
|
||||
value={item.link_label}
|
||||
onChange={(e) => updateItem(item._key, { link_label: e.target.value })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── upload_file fields ── */}
|
||||
{item.type === 'upload_file' && (
|
||||
<div className="pl-7 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Allowed File Types</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FILE_TYPE_OPTIONS.map((ft) => (
|
||||
<Badge
|
||||
key={ft.value}
|
||||
variant={(item.allowed_file_types ?? []).includes(ft.value) ? 'default' : 'outline'}
|
||||
className="cursor-pointer select-none text-xs"
|
||||
onClick={() => toggleFileType(item._key, ft.value)}
|
||||
>
|
||||
{ft.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 w-32">
|
||||
<Label className="text-xs">Max Files</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={item.max_file_count}
|
||||
onChange={(e) => updateItem(item._key, { max_file_count: parseInt(e.target.value) || 1 })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── read_course / read_unit / read_lesson fields ── */}
|
||||
{['read_course', 'read_unit', 'read_lesson'].includes(item.type) && (
|
||||
<div className="pl-7 space-y-1">
|
||||
<Label className="text-xs">
|
||||
{item.type === 'read_course' ? 'Course' : item.type === 'read_unit' ? 'Unit' : 'Lesson'}
|
||||
</Label>
|
||||
|
||||
{/* Reference selector */}
|
||||
{item.type === 'read_course' && (
|
||||
<Select
|
||||
value={item.reference_id}
|
||||
onValueChange={(v) => {
|
||||
const course = courses.find((c) => c.course_id === v);
|
||||
updateItem(item._key, {
|
||||
reference_id: v,
|
||||
reference_label: course?.title ?? '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Select a course" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{courses.map((c) => (
|
||||
<SelectItem key={c.course_id} value={c.course_id}>
|
||||
{c.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{item.type === 'read_unit' && (
|
||||
<Select
|
||||
value={item.reference_id}
|
||||
onValueChange={(v) => {
|
||||
const unit = units.find((u) => u.unit_id === v);
|
||||
updateItem(item._key, {
|
||||
reference_id: v,
|
||||
reference_label: unit?.title ?? '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Select a unit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{units.map((u) => (
|
||||
<SelectItem key={u.unit_id} value={u.unit_id}>
|
||||
{u.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{item.type === 'read_lesson' && (
|
||||
<Select
|
||||
value={item.reference_id}
|
||||
onValueChange={(v) => {
|
||||
const lesson = lessons.find((l) => l.lesson_id === v);
|
||||
updateItem(item._key, {
|
||||
reference_id: v,
|
||||
reference_label: lesson?.title ?? '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Select a lesson" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lessons.map((l) => (
|
||||
<SelectItem key={l.lesson_id} value={l.lesson_id}>
|
||||
{l.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button type="button" variant="outline" size="sm" className="w-full gap-2" onClick={addItem}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Requirement
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: <House className="size-4" />, to: '/admin' },
|
||||
{ label: 'Task List', to: '/admin/taskList' },
|
||||
{ label: 'View 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}
|
||||
pagination={pagination}
|
||||
loading={loading}
|
||||
onFetch={handleFetch}
|
||||
toolbarActions={toolbarActions}
|
||||
selectionActions={selectionActions}
|
||||
columnPinning={columnPinning}
|
||||
onRefsReady={handleRefsReady}
|
||||
/>
|
||||
|
||||
{/* ── All Groups Dialog ─────────────────────────────────────────── */}
|
||||
<Dialog open={showGroupsDialog} onOpenChange={setShowGroupsDialog}>
|
||||
<DialogContent className="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 ────────────────────────────────────────────── */}
|
||||
<ArchiveDialog
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(v) => !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 ────────────────────────────────────────────── */}
|
||||
<RestoreDialog
|
||||
open={!!restoreTarget}
|
||||
onOpenChange={(v) => !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 ──────────────────────────────────────────────── */}
|
||||
<ArchiveDialog
|
||||
open={!!bulkArchiveIds}
|
||||
onOpenChange={(v) => !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 ──────────────────────────────────────────────── */}
|
||||
<RestoreDialog
|
||||
open={!!bulkRestoreIds}
|
||||
onOpenChange={(v) => !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}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Requirement card ─────────────────────────────────────────────────────────
|
||||
function RequirementCard({ req }) {
|
||||
const cfg = REQUIREMENT_CONFIG[req.type] ?? {
|
||||
label: req.type, badgeLabel: req.type, Icon: FileText,
|
||||
};
|
||||
const { Icon } = cfg;
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
{/* 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={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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{['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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Requirements section ─────────────────────────────────────────────────────
|
||||
function TaskRequirementsSection({ requirements = [] }) {
|
||||
const sorted = [...requirements].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{sorted.length > 0 ? (
|
||||
sorted.map((req) => (
|
||||
<RequirementCard key={req.requirement_id} req={req} />
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No other requirements.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="max-w-xl 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-32 w-full" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const requirements = task.requirements ?? [];
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate(`/admin/taskList/${taskListId}/tasks`)}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="space-y-0.5">
|
||||
<h1 className="font-medium">{task.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">Task details</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
onClick={() => navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/edit`)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main content — plain div avoids Card overflow:hidden clipping */}
|
||||
<div className="rounded-lg border border-border bg-card text-card-foreground shadow-sm">
|
||||
<div className="p-4 space-y-2">
|
||||
|
||||
{/* Description */}
|
||||
{task.description ? (
|
||||
<div className="flex gap-2 text-muted-foreground">
|
||||
<FileText className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p className="text-sm leading-relaxed">{task.description}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||
)}
|
||||
|
||||
{/* Deadline */}
|
||||
{task.deadline && (
|
||||
<>
|
||||
<Separator />
|
||||
<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">
|
||||
{new Date(task.deadline).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
{task.status && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Info className="size-4" /> Status
|
||||
</div>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{task.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Requirements */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<GitPullRequest className="size-4" /> Requirements
|
||||
</div>
|
||||
<TaskRequirementsSection requirements={requirements} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import AssetList from '../pages/assets/AssetList'
|
||||
import EditAsset from '../pages/assets/EditAsset'
|
||||
|
||||
// Courses
|
||||
|
||||
import CourseList from '../pages/courses/CourseList'
|
||||
import AddCourse from '../pages/courses/AddCourse'
|
||||
import ViewCourse from '../pages/courses/ViewCourse'
|
||||
@@ -55,6 +56,18 @@ import CourseAssessment from '../pages/courses/CourseAssessment'
|
||||
import UnitQuiz from '../pages/courses/units/UnitQuiz'
|
||||
|
||||
|
||||
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: <ProtectedRoute allowedRoles={['admin']} />,
|
||||
children: [
|
||||
@@ -145,6 +158,30 @@ export const AdminRoutes = {
|
||||
},
|
||||
]
|
||||
},
|
||||
// Task
|
||||
{
|
||||
path: 'taskList',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <TaskList /> },
|
||||
{ path: 'create', element: <CreateTaskList /> },
|
||||
{ path: 'archived', element: <ArchiveTaskList /> },
|
||||
{ path: ':taskListId/view', element: <ViewTaskList /> },
|
||||
{ path: ':taskListId/edit', element: <EditTaskList /> },
|
||||
{
|
||||
path: ':taskListId/tasks',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <Tasks /> },
|
||||
{ path: 'create', element: <CreateTask /> },
|
||||
{ path: 'archived', element: <ArchivedTask /> },
|
||||
{ path: ':taskId/view', element: <ViewTask /> },
|
||||
{ path: ':taskId/edit', element: <EditTask /> },
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Add here
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user