From 8e67b84d4dff858a844f398abdbcab240e3e5d10 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Wed, 20 May 2026 13:18:44 +0800 Subject: [PATCH] add: tasks func() Signed-off-by: Kenneth Obsequio --- src/components/generic/DeadlinePicker.jsx | 122 ++++++ src/components/generic/GroupMultiSelect.jsx | 268 ++++++++++++ src/contexts/AdminTaskContext.jsx | 403 ++++++++++++++++++ src/contexts/provider/AdminProvider.jsx | 5 +- src/data/adminTiles.data.js | 4 +- .../components/task/ArchiveTaskListTable.jsx | 138 ++++++ .../components/task/ArchiveTaskTable.jsx | 147 +++++++ .../admin/components/task/TaskListTable.jsx | 193 +++++++++ .../task_list/archive/columns.config.jsx | 43 ++ .../task_list/archive/rowActions.config.jsx | 18 + .../task_list/archive/selection.config.jsx | 31 ++ .../task_list/archive/toolbar.config.jsx | 38 ++ .../admin/config/task_list/columns.config.jsx | 43 ++ .../config/task_list/rowActions.config.jsx | 35 ++ .../config/task_list/selection.config.jsx | 48 +++ .../task_list/task/archive/columns.config.jsx | 28 ++ .../task/archive/rowActions.config.jsx | 24 ++ .../task/archive/selection.config.jsx | 34 ++ .../task_list/task/archive/toolbar.config.jsx | 57 +++ .../config/task_list/task/columns.config.jsx | 28 ++ .../task_list/task/rowActions.config.jsx | 27 ++ .../task_list/task/selection.config.jsx | 34 ++ .../config/task_list/task/toolbar.config.jsx | 76 ++++ .../admin/config/task_list/toolbar.config.jsx | 76 ++++ .../admin/pages/task_list/ArchiveTaskList.jsx | 24 ++ .../admin/pages/task_list/CreateTaskList.jsx | 118 +++++ .../admin/pages/task_list/EditTaskList.jsx | 186 ++++++++ .../admin/pages/task_list/TaskList.jsx | 23 + .../admin/pages/task_list/ViewTaskList.jsx | 298 +++++++++++++ .../pages/task_list/task/ArchiveTask.jsx | 27 ++ .../admin/pages/task_list/task/CreateTask.jsx | 128 ++++++ .../admin/pages/task_list/task/EditTask.jsx | 166 ++++++++ .../task_list/task/RequirementBuilder.jsx | 282 ++++++++++++ .../admin/pages/task_list/task/Tasks.jsx | 304 +++++++++++++ .../admin/pages/task_list/task/ViewTask.jsx | 242 +++++++++++ src/modules/admin/routes/AdminRoutes.jsx | 37 ++ 36 files changed, 3752 insertions(+), 3 deletions(-) create mode 100644 src/components/generic/DeadlinePicker.jsx create mode 100644 src/components/generic/GroupMultiSelect.jsx create mode 100644 src/contexts/AdminTaskContext.jsx create mode 100644 src/modules/admin/components/task/ArchiveTaskListTable.jsx create mode 100644 src/modules/admin/components/task/ArchiveTaskTable.jsx create mode 100644 src/modules/admin/components/task/TaskListTable.jsx create mode 100644 src/modules/admin/config/task_list/archive/columns.config.jsx create mode 100644 src/modules/admin/config/task_list/archive/rowActions.config.jsx create mode 100644 src/modules/admin/config/task_list/archive/selection.config.jsx create mode 100644 src/modules/admin/config/task_list/archive/toolbar.config.jsx create mode 100644 src/modules/admin/config/task_list/columns.config.jsx create mode 100644 src/modules/admin/config/task_list/rowActions.config.jsx create mode 100644 src/modules/admin/config/task_list/selection.config.jsx create mode 100644 src/modules/admin/config/task_list/task/archive/columns.config.jsx create mode 100644 src/modules/admin/config/task_list/task/archive/rowActions.config.jsx create mode 100644 src/modules/admin/config/task_list/task/archive/selection.config.jsx create mode 100644 src/modules/admin/config/task_list/task/archive/toolbar.config.jsx create mode 100644 src/modules/admin/config/task_list/task/columns.config.jsx create mode 100644 src/modules/admin/config/task_list/task/rowActions.config.jsx create mode 100644 src/modules/admin/config/task_list/task/selection.config.jsx create mode 100644 src/modules/admin/config/task_list/task/toolbar.config.jsx create mode 100644 src/modules/admin/config/task_list/toolbar.config.jsx create mode 100644 src/modules/admin/pages/task_list/ArchiveTaskList.jsx create mode 100644 src/modules/admin/pages/task_list/CreateTaskList.jsx create mode 100644 src/modules/admin/pages/task_list/EditTaskList.jsx create mode 100644 src/modules/admin/pages/task_list/TaskList.jsx create mode 100644 src/modules/admin/pages/task_list/ViewTaskList.jsx create mode 100644 src/modules/admin/pages/task_list/task/ArchiveTask.jsx create mode 100644 src/modules/admin/pages/task_list/task/CreateTask.jsx create mode 100644 src/modules/admin/pages/task_list/task/EditTask.jsx create mode 100644 src/modules/admin/pages/task_list/task/RequirementBuilder.jsx create mode 100644 src/modules/admin/pages/task_list/task/Tasks.jsx create mode 100644 src/modules/admin/pages/task_list/task/ViewTask.jsx diff --git a/src/components/generic/DeadlinePicker.jsx b/src/components/generic/DeadlinePicker.jsx new file mode 100644 index 0000000..d306fea --- /dev/null +++ b/src/components/generic/DeadlinePicker.jsx @@ -0,0 +1,122 @@ +/*********************************************************************************************************************************************************************** + * File Name : DeadlinePicker.jsx + * Type : Reusable Component + * Description : Combined date + time picker for deadline fields. + * Controlled via a single ISO datetime string (value / onChange). + * Date is picked via a Calendar popover; time via a plain time input. + * + * Props: + * value : string | null — ISO datetime string e.g. "2026-06-01T10:30" + * onChange : (iso: string | null) => void + * disabled?: boolean + ***********************************************************************************************************************************************************************/ +import { useState } from 'react'; +import { format, parseISO, isValid } from 'date-fns'; +import { ChevronDownIcon } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function toDate(iso) { + if (!iso) return undefined; + const d = parseISO(iso); + return isValid(d) ? d : undefined; +} + +function toTimeString(iso) { + if (!iso) return '00:00'; + const d = parseISO(iso); + if (!isValid(d)) return '00:00'; + return format(d, 'HH:mm'); +} + +function buildISO(date, timeStr) { + if (!date) return null; + const [h = '00', m = '00'] = (timeStr ?? '00:00').split(':'); + const d = new Date(date); + d.setHours(Number(h), Number(m), 0, 0); + return d.toISOString(); +} + +// ───────────────────────────────────────────────────────────────────────────── +export default function DeadlinePicker({ value, onChange, disabled = false }) { + const [open, setOpen] = useState(false); + + const selectedDate = toDate(value); + const timeStr = toTimeString(value); + + const handleDateSelect = (date) => { + onChange(buildISO(date, timeStr)); + setOpen(false); + }; + + const handleTimeChange = (e) => { + onChange(buildISO(selectedDate ?? new Date(), e.target.value)); + }; + + const handleClear = () => onChange(null); + + return ( +
+ + {/* ── Date picker ──────────────────────────────────────────────── */} +
+ + + + + + + + + +
+ + {/* ── Time input ───────────────────────────────────────────────── */} +
+ + +
+ + {/* ── Clear ────────────────────────────────────────────────────── */} + {value && ( + + )} + +
+ ); +} \ No newline at end of file diff --git a/src/components/generic/GroupMultiSelect.jsx b/src/components/generic/GroupMultiSelect.jsx new file mode 100644 index 0000000..1469b8b --- /dev/null +++ b/src/components/generic/GroupMultiSelect.jsx @@ -0,0 +1,268 @@ +/*********************************************************************************************************************************************************************** + * File Name : GroupMultiSelect.jsx + * Type : Reusable Component + * Description : Searchable multi-select dropdown for User Groups. + * - Portal-based dropdown (escapes Card overflow clipping) + * - First badge + "+N" overflow chip when 2+ selected + * - "Select all" and "Clear" actions in dropdown header + * + * Props: + * value : number[] — selected group_ids + * onChange : (ids: number[]) => void + * groups? : { group_id, name }[] — skip API fetch if provided + * disabled? : boolean + * placeholder?: string + ***********************************************************************************************************************************************************************/ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import api from '@/utils/api.util'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; +import { Check, ChevronsUpDown, X, Users } from 'lucide-react'; + +export default function GroupMultiSelect({ + value = [], + onChange, + groups: groupsProp = null, + disabled = false, + placeholder = 'Select groups…', +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const [allGroups, setAllGroups] = useState(groupsProp ?? []); + const [loadingGroups, setLoadingGroups] = useState(!groupsProp); + const [dropdownStyle, setDropdownStyle] = useState({}); + + const triggerRef = useRef(null); + const dropdownRef = useRef(null); + + // ── Fetch groups if not provided by parent ──────────────────────────────── + useEffect(() => { + if (groupsProp !== null) { + setAllGroups(groupsProp); + setLoadingGroups(false); + return; + } + let cancelled = false; + setLoadingGroups(true); + api.get('/admin/groups', { params: { limit: 500 } }) + .then((res) => { + if (!cancelled) { + const raw = res.data?.data?.data ?? res.data?.data ?? []; + setAllGroups(Array.isArray(raw) ? raw : []); + } + }) + .catch(() => { if (!cancelled) setAllGroups([]); }) + .finally(() => { if (!cancelled) setLoadingGroups(false); }); + return () => { cancelled = true; }; + }, [groupsProp]); + + // ── Position portal dropdown under trigger ──────────────────────────────── + useEffect(() => { + if (!open || !triggerRef.current) return; + + const reposition = () => { + const rect = triggerRef.current.getBoundingClientRect(); + setDropdownStyle({ + position: 'fixed', + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + zIndex: 9999, + }); + }; + + reposition(); + window.addEventListener('scroll', reposition, true); + window.addEventListener('resize', reposition); + return () => { + window.removeEventListener('scroll', reposition, true); + window.removeEventListener('resize', reposition); + }; + }, [open]); + + // ── Close on outside click ──────────────────────────────────────────────── + useEffect(() => { + if (!open) return; + const handler = (e) => { + if ( + triggerRef.current?.contains(e.target) || + dropdownRef.current?.contains(e.target) + ) return; + setOpen(false); + setSearch(''); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + // ── Helpers ─────────────────────────────────────────────────────────────── + const filtered = allGroups.filter((g) => + g.name.toLowerCase().includes(search.toLowerCase()) + ); + const selectedGroups = allGroups.filter((g) => value.includes(g.group_id)); + const overflowCount = selectedGroups.length - 1; + + // All currently visible (filtered) IDs — used for select-all scope + const filteredIds = filtered.map((g) => g.group_id); + const allFilteredSelected = filteredIds.length > 0 && filteredIds.every((id) => value.includes(id)); + + const toggle = (groupId) => + onChange(value.includes(groupId) + ? value.filter((id) => id !== groupId) + : [...value, groupId] + ); + + const remove = (e, groupId) => { + e.stopPropagation(); + onChange(value.filter((id) => id !== groupId)); + }; + + // Select all visible (filtered) groups + const handleSelectAll = () => { + const merged = Array.from(new Set([...value, ...filteredIds])); + onChange(merged); + }; + + // Clear all selections + const handleClear = () => onChange([]); + + // ── Portal dropdown ─────────────────────────────────────────────────────── + const dropdown = open && createPortal( +
+ {/* Search row */} +
+ setSearch(e.target.value)} + placeholder="Search groups…" + className="h-8 text-sm" + /> +
+ + {/* Select all / Clear row — only when groups are loaded */} + {!loadingGroups && allGroups.length > 0 && ( +
+ + {value.length > 0 && ( + + )} +
+ )} + + {/* Options */} +
    + {loadingGroups ? ( +
  • + Loading groups… +
  • + ) : filtered.length === 0 ? ( +
  • + No groups found. +
  • + ) : ( + filtered.map((g) => { + const selected = value.includes(g.group_id); + return ( +
  • e.preventDefault()} + onClick={() => toggle(g.group_id)} + className={cn( + 'flex items-center gap-2 px-3 py-2 text-sm cursor-pointer select-none', + 'hover:bg-accent hover:text-accent-foreground', + selected && 'bg-accent/50' + )} + > +
    + {selected && } +
    + + {g.name} +
  • + ); + }) + )} +
+
, + document.body + ); + + return ( + <> + {/* ── Trigger button ───────────────────────────────────────────── */} + + + {/* ── Portalled dropdown ────────────────────────────────────────── */} + {dropdown} + + ); +} \ No newline at end of file diff --git a/src/contexts/AdminTaskContext.jsx b/src/contexts/AdminTaskContext.jsx new file mode 100644 index 0000000..0c8de41 --- /dev/null +++ b/src/contexts/AdminTaskContext.jsx @@ -0,0 +1,403 @@ +/*********************************************************************************************************************************************************************** + * File Name : AdminTaskContext.jsx + * Type : Context / Provider + * Description : Admin task management context. + * Covers: task lists (list, get, create, update, archive, restore, bulk archive, bulk restore) + * tasks (list, get, create, update, archive, restore, bulk archive, bulk restore) + * task list groups (list assigned, assign, unassign) + ***********************************************************************************************************************************************************************/ +import { createContext, useCallback, useContext, useState } from 'react'; +import api from '@/utils/api.util'; +import { toast } from 'sonner'; + +const BASE = '/admin/task-lists'; + +// ─── Context ────────────────────────────────────────────────────────────────── +const AdminTaskContext = createContext(null); + +export function useAdminTask() { + const ctx = useContext(AdminTaskContext); + if (!ctx) throw new Error('useAdminTask must be used within an AdminTaskProvider'); + return ctx; +} + +// ─── Provider ───────────────────────────────────────────────────────────────── +export function AdminTaskProvider({ children }) { + // ── Task List state ─────────────────────────────────────────────────────── + const [taskLists, setTaskLists] = useState([]); + const [taskList, setTaskList] = useState(null); + + // ── Task state ──────────────────────────────────────────────────────────── + const [tasks, setTasks] = useState([]); + const [task, setTask] = useState(null); + + // ── Task List Groups state ──────────────────────────────────────────────── + const [taskListGroups, setTaskListGroups] = useState([]); + + // ── Shared ──────────────────────────────────────────────────────────────── + const [attributes, setAttributes] = useState([]); + const [pagination, setPagination] = useState({ page: 1, limit: 10, totalRecords: 0, totalPages: 1 }); + const [loading, setLoading] = useState(false); + + // ─── Generic request wrapper ────────────────────────────────────────────── + const request = useCallback(async (fn) => { + setLoading(true); + try { + return await fn(); + } catch (err) { + const message = err?.response?.data?.message ?? 'Something went wrong.'; + toast.error(message); + return null; + } finally { + setLoading(false); + } + }, []); + + // ══════════════════════════════════════════════════════════════════════════ + // TASK LISTS + // ══════════════════════════════════════════════════════════════════════════ + + // ─── GET ALL ────────────────────────────────────────────────────────────── + const fetchTaskLists = useCallback( + ({ page = 1, limit = 10, filters = [], sort = [], archived = false } = {}) => + request(async () => { + const res = await api.get(BASE, { + params: { + page, + limit, + filters: JSON.stringify(filters), + sort: JSON.stringify(sort), + archived: archived ? 'true' : undefined, + }, + }); + + const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {}; + + setTaskLists(data ?? []); + setAttributes(attrs ?? []); + setPagination(pg ?? { page: 1, limit: 10, totalRecords: 0, totalPages: 1 }); + }), + [request] + ); + + // ─── GET ONE ────────────────────────────────────────────────────────────── + // Response now includes a `groups` array on the task list object. + const fetchTaskList = useCallback( + (taskListId) => + request(async () => { + const res = await api.get(`${BASE}/${taskListId}`); + const taskListData = res.data?.data ?? null; + setTaskList(taskListData); + // Sync the groups slice from the embedded payload so consumers + // don't have to call fetchTaskListGroups separately after a getOne. + if (taskListData?.groups) setTaskListGroups(taskListData.groups); + return taskListData; + }), + [request] + ); + + // ─── GET ARCHIVED TASK LISTS ────────────────────────────────────────────── + const fetchArchivedTaskLists = useCallback( + ({ page = 1, limit = 10, filters = [], sort = [] } = {}) => + request(async () => { + const res = await api.get(`${BASE}/archived`, { + params: { + page, + limit, + filters: JSON.stringify(filters), + sort: JSON.stringify(sort), + }, + }); + + const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {}; + + setTaskLists(data ?? []); + setAttributes(attrs ?? []); + setPagination(pg ?? { page: 1, limit: 10, totalRecords: 0, totalPages: 1 }); + }), + [request] + ); + + // ─── GET ARCHIVED TASKS ─────────────────────────────────────────────────── + const fetchArchivedTasks = useCallback( + (taskListId, { page = 1, limit = 10, filters = [], sort = [] } = {}) => + request(async () => { + const res = await api.get(`${BASE}/${taskListId}/tasks/archived`, { + params: { + page, + limit, + filters: JSON.stringify(filters), + sort: JSON.stringify(sort), + }, + }); + + const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {}; + + setTasks(data ?? []); + setAttributes(attrs ?? []); + setPagination(pg ?? { page: 1, limit: 10, totalRecords: 0, totalPages: 1 }); + }), + [request] + ); + + // ─── CREATE ─────────────────────────────────────────────────────────────── + const createTaskList = useCallback( + (payload) => + request(async () => { + const res = await api.post(BASE, payload); + toast.success('Task list created.'); + return res.data?.data ?? null; + }), + [request] + ); + + // ─── UPDATE ─────────────────────────────────────────────────────────────── + const updateTaskList = useCallback( + (taskListId, payload) => + request(async () => { + const res = await api.patch(`${BASE}/${taskListId}`, payload); + toast.success('Task list updated.'); + return res.data?.data?.data ?? null; + }), + [request] + ); + + // ─── ARCHIVE ────────────────────────────────────────────────────────────── + const archiveTaskList = useCallback( + (taskListId) => + request(async () => { + await api.delete(`${BASE}/${taskListId}`); + toast.success('Task list archived.'); + return true; + }), + [request] + ); + + // ─── RESTORE ────────────────────────────────────────────────────────────── + const restoreTaskList = useCallback( + (taskListId) => + request(async () => { + await api.patch(`${BASE}/${taskListId}/restore`); + toast.success('Task list restored.'); + return true; + }), + [request] + ); + + // ─── BULK ARCHIVE ───────────────────────────────────────────────────────── + const bulkArchiveTaskLists = useCallback( + (ids) => + request(async () => { + await api.post(`${BASE}/bulk-archive`, { ids }); + toast.success(`${ids.length} task list(s) archived.`); + return true; + }), + [request] + ); + + // ─── BULK RESTORE ───────────────────────────────────────────────────────── + const bulkRestoreTaskLists = useCallback( + (ids) => + request(async () => { + await api.post(`${BASE}/bulk-restore`, { ids }); + toast.success(`${ids.length} task list(s) restored.`); + return true; + }), + [request] + ); + + // ══════════════════════════════════════════════════════════════════════════ + // TASK LIST GROUPS + // ══════════════════════════════════════════════════════════════════════════ + + // ─── GET ASSIGNED GROUPS ────────────────────────────────────────────────── + // GET /admin/task-lists/:taskListId/groups + const fetchTaskListGroups = useCallback( + (taskListId) => + request(async () => { + const res = await api.get(`${BASE}/${taskListId}/groups`); + const groups = res.data?.data ?? []; + setTaskListGroups(groups); + return groups; + }), + [request] + ); + + // ─── ASSIGN GROUPS ──────────────────────────────────────────────────────── + // POST /admin/task-lists/:taskListId/groups/assign + // payload: { group_ids: number[] } + // + // Returns summary: { assigned_ids, already_assigned_ids, invalid_ids } + const assignGroups = useCallback( + (taskListId, groupIds) => + request(async () => { + const res = await api.post(`${BASE}/${taskListId}/groups/assign`, { + group_ids: groupIds, + }); + const result = res.data?.data ?? {}; + if (result.assigned_ids?.length) { + toast.success(`${result.assigned_ids.length} group(s) assigned.`); + } else { + toast.info('All selected groups were already assigned.'); + } + return result; + }), + [request] + ); + + // ─── UNASSIGN GROUPS ────────────────────────────────────────────────────── + // POST /admin/task-lists/:taskListId/groups/unassign + // payload: { group_ids: number[] } + // + // Returns summary: { unassigned_ids, skipped_ids } + const unassignGroups = useCallback( + (taskListId, groupIds) => + request(async () => { + const res = await api.post(`${BASE}/${taskListId}/groups/unassign`, { + group_ids: groupIds, + }); + const result = res.data?.data ?? {}; + toast.success(`${result.unassigned_ids?.length ?? 0} group(s) unassigned.`); + return result; + }), + [request] + ); + + // ══════════════════════════════════════════════════════════════════════════ + // TASKS + // ══════════════════════════════════════════════════════════════════════════ + + // ─── GET ALL ────────────────────────────────────────────────────────────── + const fetchTasks = useCallback( + (taskListId, { page = 1, limit = 10, filters = [], sort = [], archived = false } = {}) => + request(async () => { + const res = await api.get(`${BASE}/${taskListId}/tasks`, { + params: { + page, + limit, + filters: JSON.stringify(filters), + sort: JSON.stringify(sort), + archived: archived ? 'true' : undefined, + }, + }); + + const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {}; + + setTasks(data ?? []); + setAttributes(attrs ?? []); + setPagination(pg ?? { page: 1, limit: 10, totalRecords: 0, totalPages: 1 }); + }), + [request] + ); + + // ─── GET ONE ────────────────────────────────────────────────────────────── + const fetchTask = useCallback( + (taskListId, taskId) => + request(async () => { + const res = await api.get(`${BASE}/${taskListId}/tasks/${taskId}`); + const taskData = res.data?.data ?? null; + + setTask(taskData); + return taskData; + }), + [request] + ); + + // ─── CREATE ─────────────────────────────────────────────────────────────── + const createTask = useCallback( + (taskListId, payload) => + request(async () => { + const res = await api.post(`${BASE}/${taskListId}/tasks`, payload); + toast.success('Task created.'); + return res.data?.data?.data ?? null; + }), + [request] + ); + + // ─── UPDATE ─────────────────────────────────────────────────────────────── + const updateTask = useCallback( + (taskListId, taskId, payload) => + request(async () => { + const res = await api.patch(`${BASE}/${taskListId}/tasks/${taskId}`, payload); + toast.success('Task updated.'); + return res.data?.data?.data ?? null; + }), + [request] + ); + + // ─── ARCHIVE ────────────────────────────────────────────────────────────── + const archiveTask = useCallback( + (taskListId, taskId) => + request(async () => { + await api.delete(`${BASE}/${taskListId}/tasks/${taskId}`); + toast.success('Task archived.'); + return true; + }), + [request] + ); + + // ─── RESTORE ────────────────────────────────────────────────────────────── + const restoreTask = useCallback( + (taskListId, taskId) => + request(async () => { + await api.patch(`${BASE}/${taskListId}/tasks/${taskId}/restore`); + toast.success('Task restored.'); + return true; + }), + [request] + ); + + // ─── BULK ARCHIVE ───────────────────────────────────────────────────────── + const bulkArchiveTasks = useCallback( + (taskListId, ids) => + request(async () => { + await api.post(`${BASE}/${taskListId}/tasks/bulk-archive`, { ids }); + toast.success(`${ids.length} task(s) archived.`); + return true; + }), + [request] + ); + + // ─── BULK RESTORE ───────────────────────────────────────────────────────── + const bulkRestoreTasks = useCallback( + (taskListId, ids) => + request(async () => { + await api.post(`${BASE}/${taskListId}/tasks/bulk-restore`, { ids }); + toast.success(`${ids.length} task(s) restored.`); + return true; + }), + [request] + ); + + // ───────────────────────────────────────────────────────────────────────── + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/contexts/provider/AdminProvider.jsx b/src/contexts/provider/AdminProvider.jsx index b5aa87f..139a3e0 100644 --- a/src/contexts/provider/AdminProvider.jsx +++ b/src/contexts/provider/AdminProvider.jsx @@ -4,6 +4,7 @@ import { AdminDashboardProvider } from "../AdminDashboardContext" import { UserProvider } from "../AdminUserContext"; import { UserGroupProvider } from "../AdminUserGroupContext"; import { CoursesProvider } from "../AdminCoursesContext"; +import { AdminTaskProvider } from "../AdminTaskContext"; export const AdminProvider = ({ children }) => { return ( @@ -12,7 +13,9 @@ export const AdminProvider = ({ children }) => { - {children} + + {children} + diff --git a/src/data/adminTiles.data.js b/src/data/adminTiles.data.js index 25b373b..4861c94 100644 --- a/src/data/adminTiles.data.js +++ b/src/data/adminTiles.data.js @@ -26,8 +26,8 @@ export const ADMIN_SECTIONS = [ title: "Content Management", description: "Manage tasks and courses", tiles: [ - { key: "assets", label: "Courses", icon: BookText, link: "/admin/courses" }, - { key: "assets", label: "Tasks", icon: ListCheck, link: "" }, + { key: "courses", label: "Courses", icon: BookText, link: "/admin/courses" }, + { key: "tasks", label: "Tasks", icon: ListCheck, link: "/admin/taskList" }, ], }, { diff --git a/src/modules/admin/components/task/ArchiveTaskListTable.jsx b/src/modules/admin/components/task/ArchiveTaskListTable.jsx new file mode 100644 index 0000000..64ac1d1 --- /dev/null +++ b/src/modules/admin/components/task/ArchiveTaskListTable.jsx @@ -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 ( + <> + []} + onRefsReady={handleRefsReady} + renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="archived task list" + emptyMessage="No archived task lists found." + /> + + {/* Single restore */} + !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 */} + !v && setRestoreIds(null)} + ids={restoreIds ?? []} + entityLabel="Task List" + onRestore={(ids) => bulkRestoreTaskLists(ids)} + loading={loading} + onSuccess={handleSuccess} + /> + + ); +} \ No newline at end of file diff --git a/src/modules/admin/components/task/ArchiveTaskTable.jsx b/src/modules/admin/components/task/ArchiveTaskTable.jsx new file mode 100644 index 0000000..9d5d476 --- /dev/null +++ b/src/modules/admin/components/task/ArchiveTaskTable.jsx @@ -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 ( + <> + []} + onRefsReady={handleRefsReady} + renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="archived task" + emptyMessage="No archived tasks found." + /> + + {/* Single restore */} + !v && setRestoreTarget(null)} + entity={restoreTarget} + entityLabel="Task" + getName={(r) => r?.name} + onRestore={(r) => restoreTask(taskListId, r?.task_id)} + loading={loading} + onSuccess={handleSuccess} + /> + + {/* Bulk restore */} + !v && setRestoreIds(null)} + ids={restoreIds ?? []} + entityLabel="Task" + onRestore={(ids) => bulkRestoreTasks(taskListId, ids)} + loading={loading} + onSuccess={handleSuccess} + /> + + ); +} \ No newline at end of file diff --git a/src/modules/admin/components/task/TaskListTable.jsx b/src/modules/admin/components/task/TaskListTable.jsx new file mode 100644 index 0000000..ce8d05e --- /dev/null +++ b/src/modules/admin/components/task/TaskListTable.jsx @@ -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 ( + <> + []} + onRefsReady={handleRefsReady} + renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="task list" + emptyMessage="No task lists found." + /> + + {/* Single archive */} + !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 */} + !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 */} + !v && setArchiveIds(null)} + ids={archiveIds ?? []} + entityLabel="Task List" + onArchive={(ids) => bulkArchiveTaskLists(ids)} + loading={loading} + onSuccess={handleSuccess} + /> + + {/* Bulk restore */} + !v && setRestoreIds(null)} + ids={restoreIds ?? []} + entityLabel="Task List" + onRestore={(ids) => bulkRestoreTaskLists(ids)} + loading={loading} + onSuccess={handleSuccess} + /> + + ); +} \ No newline at end of file diff --git a/src/modules/admin/config/task_list/archive/columns.config.jsx b/src/modules/admin/config/task_list/archive/columns.config.jsx new file mode 100644 index 0000000..e495bfd --- /dev/null +++ b/src/modules/admin/config/task_list/archive/columns.config.jsx @@ -0,0 +1,43 @@ +// config/columns.config.jsx +// Column definitions and pinning config for the Task List table. + +import { buildColumns } from "@/utils/table.util"; +import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn"; +import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn"; + +export const columnPinning = { + right: ["actions"], + left: [], +}; + +const cellOverrides = { + // memberCount: (info) => { + // const count = parseInt(info.getValue() ?? 0, 10); + // return ( + //
+ // + // + // {count} {count === 1 ? "member" : "members"} + // + //
+ // ); + // }, +}; + + +/** + * Builds the full column array for the Users table. + * + * @param {Array} attributes Field definitions from the server (drives data columns) + * @param {Array} rowActions Row-level kebab action definitions + * @returns {Array} TanStack column definitions + */ +export function buildDataColumns(attributes, rowActions) { + const visibleAttributes = attributes.filter((a) => !a.hidden); + + return [ + buildSelectionColumn(), + ...buildColumns(visibleAttributes, { cellOverrides }), + buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }), + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/task_list/archive/rowActions.config.jsx b/src/modules/admin/config/task_list/archive/rowActions.config.jsx new file mode 100644 index 0000000..1438e71 --- /dev/null +++ b/src/modules/admin/config/task_list/archive/rowActions.config.jsx @@ -0,0 +1,18 @@ +import { RotateCcw, Eye } from "lucide-react"; + +export function buildRowActions({ navigate, onRestore }) { + return [ + { + key: "view", + label: "View", + icon: , + onClick: (row) => navigate(`/admin/taskList/${row.task_list_id}/view`), + }, + { + key: "restore", + label: "Restore", + icon: , + onClick: (row) => onRestore(row), + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/task_list/archive/selection.config.jsx b/src/modules/admin/config/task_list/archive/selection.config.jsx new file mode 100644 index 0000000..11bd773 --- /dev/null +++ b/src/modules/admin/config/task_list/archive/selection.config.jsx @@ -0,0 +1,31 @@ +import { RotateCcw } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; +import { Download } from "lucide-react"; + +export function buildSelectionActions({ + exportConfig, + onRestoreMany, + getTableInstance, +}) { + return [ + { + key: "export-selected", + label: "Export Selected", + icon: , + onClick: () => + exportTableToExcel({ + ...exportConfig, + tableInstance: getTableInstance?.(), + selectedOnly: true, + }), + }, + { + key: "bulk-restore", + label: "Restore Selected", + icon: , + variant: "outline", + onClick: (selectedRows) => + onRestoreMany(selectedRows.map((r) => r.task_list_id)), + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/task_list/archive/toolbar.config.jsx b/src/modules/admin/config/task_list/archive/toolbar.config.jsx new file mode 100644 index 0000000..d73ea08 --- /dev/null +++ b/src/modules/admin/config/task_list/archive/toolbar.config.jsx @@ -0,0 +1,38 @@ +import { RefreshCw, Download } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +export function buildToolbarActions({ + fetchArchivedTaskLists, + pagination, + exportConfig, + getFilters, + getSort, + getTableInstance, +}) { + return [ + { + key: "refresh", + type: "button", + icon: , + label: "Refresh", + onClick: () => + fetchArchivedTaskLists({ + page: 1, + limit: pagination?.limit ?? 10, + filters: getFilters?.() ?? [], + sort: getSort?.() ?? [], + }), + }, + { + key: "export", + type: "button", + icon: , + label: "Export", + onClick: (table) => + exportTableToExcel({ + ...exportConfig, + tableInstance: table ?? getTableInstance?.(), + }), + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/task_list/columns.config.jsx b/src/modules/admin/config/task_list/columns.config.jsx new file mode 100644 index 0000000..e495bfd --- /dev/null +++ b/src/modules/admin/config/task_list/columns.config.jsx @@ -0,0 +1,43 @@ +// config/columns.config.jsx +// Column definitions and pinning config for the Task List table. + +import { buildColumns } from "@/utils/table.util"; +import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn"; +import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn"; + +export const columnPinning = { + right: ["actions"], + left: [], +}; + +const cellOverrides = { + // memberCount: (info) => { + // const count = parseInt(info.getValue() ?? 0, 10); + // return ( + //
+ // + // + // {count} {count === 1 ? "member" : "members"} + // + //
+ // ); + // }, +}; + + +/** + * Builds the full column array for the Users table. + * + * @param {Array} attributes Field definitions from the server (drives data columns) + * @param {Array} rowActions Row-level kebab action definitions + * @returns {Array} TanStack column definitions + */ +export function buildDataColumns(attributes, rowActions) { + const visibleAttributes = attributes.filter((a) => !a.hidden); + + return [ + buildSelectionColumn(), + ...buildColumns(visibleAttributes, { cellOverrides }), + buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }), + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/task_list/rowActions.config.jsx b/src/modules/admin/config/task_list/rowActions.config.jsx new file mode 100644 index 0000000..e574c1d --- /dev/null +++ b/src/modules/admin/config/task_list/rowActions.config.jsx @@ -0,0 +1,35 @@ +import { Eye, Pencil, Archive, ArchiveRestore, NotebookPen, Info } from "lucide-react"; + +export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) { + return [ + { + key: "edit", + label: "View Info", + icon: , + onClick: (row) => navigate(`${row.task_list_id}/view`), + }, + { + key: "edit", + label: "Edit Info", + icon: , + onClick: (row) => navigate(`${row.task_list_id}/edit`), + }, + { + key: "view", + label: "View Tasks", + icon: , + onClick: (row) => navigate(`${row.task_list_id}/tasks`), + separator: true, + className: "text-sky-800" + }, + { + key: "archive", + label: "Archive", + className: "text-destructive focus:text-destructive", + icon: , + onClick: (row) => onArchive(row), + hidden: () => showArchived, + separator: true, + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/task_list/selection.config.jsx b/src/modules/admin/config/task_list/selection.config.jsx new file mode 100644 index 0000000..3543fe2 --- /dev/null +++ b/src/modules/admin/config/task_list/selection.config.jsx @@ -0,0 +1,48 @@ +// config/selection.config.jsx +import { Download, Archive, RotateCcw } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +/** + * @param {Object} deps + * @param {Object} deps.exportConfig + * @param {boolean} deps.showArchived + * @param {Function} deps.onBulkArchive + * @param {Function} deps.onBulkRestore + * @param {Function} deps.getTableInstance + */ +export function buildSelectionActions({ + exportConfig, + showArchived, + onBulkArchive, + onBulkRestore, + getTableInstance, +}) { + return [ + { + key: "export-selected", + label: "Export", + icon: , + onClick: (rows, table) => + exportTableToExcel({ + ...exportConfig, + selectedRows: rows, + tableInstance: table ?? getTableInstance?.(), + }), + }, + { + key: showArchived ? "restore-selected" : "archive-selected", + label: showArchived ? "Restore" : "Archive", + icon: showArchived ? ( + + ) : ( + + ), + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.task_id).filter(Boolean); + if (!ids.length) return; + showArchived ? onBulkRestore?.(ids) : onBulkArchive?.(ids); + }, + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/task_list/task/archive/columns.config.jsx b/src/modules/admin/config/task_list/task/archive/columns.config.jsx new file mode 100644 index 0000000..889c7c0 --- /dev/null +++ b/src/modules/admin/config/task_list/task/archive/columns.config.jsx @@ -0,0 +1,28 @@ +// config/columns.config.jsx +// Column definitions and pinning config for the Users table. + +import { buildColumns } from "@/utils/table.util"; +import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn"; +import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn"; + +export const columnPinning = { + right: ["actions"], + left: [], +}; + +/** + * Builds the full column array for the Users table. + * + * @param {Array} attributes Field definitions from the server (drives data columns) + * @param {Array} rowActions Row-level kebab action definitions + * @returns {Array} TanStack column definitions + */ +export function buildDataColumns(attributes, rowActions) { + const visibleAttributes = attributes.filter((a) => !a.hidden); + + return [ + buildSelectionColumn(), + ...buildColumns(visibleAttributes), + buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }), + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/task_list/task/archive/rowActions.config.jsx b/src/modules/admin/config/task_list/task/archive/rowActions.config.jsx new file mode 100644 index 0000000..ac6a925 --- /dev/null +++ b/src/modules/admin/config/task_list/task/archive/rowActions.config.jsx @@ -0,0 +1,24 @@ +// config/rowActions.config.jsx +// Per-row kebab menu action definitions for the Users table. +// +// Each onClick receives the row's data object from buildRowActionsColumn. + +import { RotateCcw } from "lucide-react"; + +/** + * @param {Object} deps + * @param {Function} deps.navigate React Router navigate + * @param {Function} deps.archiveUser Archive handler from useManagement + * @returns {Array} rowActions + */ +export function buildRowActions({ navigate, onRestore }) { + return [ + { + key: "restore", + label: "Restore", + icon: , + onClick: (row) => onRestore(row), + hidden: (row) => row.is_active, + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/task_list/task/archive/selection.config.jsx b/src/modules/admin/config/task_list/task/archive/selection.config.jsx new file mode 100644 index 0000000..af85009 --- /dev/null +++ b/src/modules/admin/config/task_list/task/archive/selection.config.jsx @@ -0,0 +1,34 @@ +// config/selection.config.jsx +import { Download, Archive, Trash2 } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +/** + * @param {Object} deps + * @param {Object} deps.exportConfig { allData, attributes, filename, sheetName } + * @param {Function} deps.archiveUser Archive handler from useManagement + * @param {Function} deps.deleteUser Delete handler from useManagement + */ +export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers, getTableInstance }) { + return [ + { + key: "export-selected", + label: "Export", + icon: , + onClick: (rows, table) => + exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table ?? getTableInstance() }), + }, + { + key: "archive-selected", + label: "Archive", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.user_id); + ids.length === 1 + ? archiveUser(rows[0]) // opens single dialog + : archiveUsers(ids); // opens bulk dialog + }, + hidden: (rows) => rows.every((r) => r.status === "archived"), + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/task_list/task/archive/toolbar.config.jsx b/src/modules/admin/config/task_list/task/archive/toolbar.config.jsx new file mode 100644 index 0000000..a6c7b9b --- /dev/null +++ b/src/modules/admin/config/task_list/task/archive/toolbar.config.jsx @@ -0,0 +1,57 @@ +// ─── config/toolbar.config.jsx ──────────────────────────────────────────────── +import { RefreshCw, Download, Plus, Archive } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +/** + * @param {Object} deps + * @param {Function} deps.fetchTasks + * @param {string} deps.taskListId + * @param {Object} deps.pagination + * @param {Object} deps.exportConfig + * @param {Function} deps.navigate + * @param {boolean} deps.showArchived + * @param {Function} deps.onToggleArchived + * @param {Function} deps.getFilters + * @param {Function} deps.getSort + * @param {Function} deps.getTableInstance + */ +export function buildToolbarActions({ + fetchTasks, + taskListId, + pagination, + exportConfig, + navigate, + showArchived, + onToggleArchived, + getFilters, + getSort, + getTableInstance, +}) { + return [ + { + key: "refresh", + type: "button", + icon: , + label: "Refresh", + onClick: () => + fetchTasks(taskId, { + page: 1, + limit: pagination?.limit ?? 10, + filters: getFilters?.() ?? [], + sort: getSort?.() ?? [], + archived: showArchived, + }), + }, + { + key: "export", + type: "button", + icon: , + label: "Export", + onClick: (table) => + exportTableToExcel({ + ...exportConfig, + tableInstance: table ?? getTableInstance?.(), + }), + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/task_list/task/columns.config.jsx b/src/modules/admin/config/task_list/task/columns.config.jsx new file mode 100644 index 0000000..889c7c0 --- /dev/null +++ b/src/modules/admin/config/task_list/task/columns.config.jsx @@ -0,0 +1,28 @@ +// config/columns.config.jsx +// Column definitions and pinning config for the Users table. + +import { buildColumns } from "@/utils/table.util"; +import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn"; +import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn"; + +export const columnPinning = { + right: ["actions"], + left: [], +}; + +/** + * Builds the full column array for the Users table. + * + * @param {Array} attributes Field definitions from the server (drives data columns) + * @param {Array} rowActions Row-level kebab action definitions + * @returns {Array} TanStack column definitions + */ +export function buildDataColumns(attributes, rowActions) { + const visibleAttributes = attributes.filter((a) => !a.hidden); + + return [ + buildSelectionColumn(), + ...buildColumns(visibleAttributes), + buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }), + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/task_list/task/rowActions.config.jsx b/src/modules/admin/config/task_list/task/rowActions.config.jsx new file mode 100644 index 0000000..2bbc912 --- /dev/null +++ b/src/modules/admin/config/task_list/task/rowActions.config.jsx @@ -0,0 +1,27 @@ +import { Eye, Pencil, Archive, ArchiveRestore, Info } from "lucide-react"; + +export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) { + return [ + { + key: "edit", + label: "View Info", + icon: , + onClick: (row) => navigate(`${row.task_id}/view`), + }, + { + key: "edit", + label: "Edit Info", + icon: , + onClick: (row) => navigate(`${row.task_id}/edit`), + }, + { + key: "archive", + label: "Archive", + className: "text-destructive focus:text-destructive", + icon: , + onClick: (row) => onArchive(row), + hidden: () => showArchived, + separator: true, + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/task_list/task/selection.config.jsx b/src/modules/admin/config/task_list/task/selection.config.jsx new file mode 100644 index 0000000..af85009 --- /dev/null +++ b/src/modules/admin/config/task_list/task/selection.config.jsx @@ -0,0 +1,34 @@ +// config/selection.config.jsx +import { Download, Archive, Trash2 } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +/** + * @param {Object} deps + * @param {Object} deps.exportConfig { allData, attributes, filename, sheetName } + * @param {Function} deps.archiveUser Archive handler from useManagement + * @param {Function} deps.deleteUser Delete handler from useManagement + */ +export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers, getTableInstance }) { + return [ + { + key: "export-selected", + label: "Export", + icon: , + onClick: (rows, table) => + exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table ?? getTableInstance() }), + }, + { + key: "archive-selected", + label: "Archive", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.user_id); + ids.length === 1 + ? archiveUser(rows[0]) // opens single dialog + : archiveUsers(ids); // opens bulk dialog + }, + hidden: (rows) => rows.every((r) => r.status === "archived"), + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/task_list/task/toolbar.config.jsx b/src/modules/admin/config/task_list/task/toolbar.config.jsx new file mode 100644 index 0000000..9103bca --- /dev/null +++ b/src/modules/admin/config/task_list/task/toolbar.config.jsx @@ -0,0 +1,76 @@ +// ─── config/toolbar.config.jsx ──────────────────────────────────────────────── +import { RefreshCw, Download, Plus, Archive } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +/** + * @param {Object} deps + * @param {Function} deps.fetchTasks + * @param {string} deps.taskListId + * @param {Object} deps.pagination + * @param {Object} deps.exportConfig + * @param {Function} deps.navigate + * @param {boolean} deps.showArchived + * @param {Function} deps.onToggleArchived + * @param {Function} deps.getFilters + * @param {Function} deps.getSort + * @param {Function} deps.getTableInstance + */ +export function buildToolbarActions({ + fetchTasks, + taskListId, + pagination, + exportConfig, + navigate, + showArchived, + onToggleArchived, + getFilters, + getSort, + getTableInstance, +}) { + return [ + { + key: "refresh", + type: "button", + icon: , + label: "Refresh", + onClick: () => + fetchTasks(taskId, { + page: 1, + limit: pagination?.limit ?? 10, + filters: getFilters?.() ?? [], + sort: getSort?.() ?? [], + archived: showArchived, + }), + }, + { + key: "export", + type: "button", + icon: , + label: "Export", + onClick: (table) => + exportTableToExcel({ + ...exportConfig, + tableInstance: table ?? getTableInstance?.(), + }), + }, + { + key: "add-task", + type: "button", + icon: , + label: "Create Task", + variant: "default", + className: "text-primary-foreground", + onClick: () => navigate(`/admin/taskList/${taskListId}/tasks/create`), + }, + { + key: "toggle-archived-task", + type: "button", + icon: , + label: showArchived ? "Active Tasks" : "Archived Tasks", + variant: "secondary", + className: "border border-border", + // onClick: onToggleArchived, + onClick: () => navigate(`/admin/taskList/${taskListId}/tasks/archived`), + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/task_list/toolbar.config.jsx b/src/modules/admin/config/task_list/toolbar.config.jsx new file mode 100644 index 0000000..e4e608f --- /dev/null +++ b/src/modules/admin/config/task_list/toolbar.config.jsx @@ -0,0 +1,76 @@ +// ─── config/toolbar.config.jsx ──────────────────────────────────────────────── +import { RefreshCw, Download, Plus, Archive } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +/** + * @param {Object} deps + * @param {Function} deps.fetchTasks + * @param {string} deps.taskListId + * @param {Object} deps.pagination + * @param {Object} deps.exportConfig + * @param {Function} deps.navigate + * @param {boolean} deps.showArchived + * @param {Function} deps.onToggleArchived + * @param {Function} deps.getFilters + * @param {Function} deps.getSort + * @param {Function} deps.getTableInstance + */ +export function buildToolbarActions({ + fetchTasks, + fetchArchivedTaskLists, + taskListId, + pagination, + exportConfig, + navigate, + showArchived, + onToggleArchived, + getFilters, + getSort, + getTableInstance, +}) { + return [ + { + key: "refresh", + type: "button", + icon: , + label: "Refresh", + onClick: () => + fetchTasks(taskListId, { + page: 1, + limit: pagination?.limit ?? 10, + filters: getFilters?.() ?? [], + sort: getSort?.() ?? [], + archived: showArchived, + }), + }, + { + key: "export", + type: "button", + icon: , + label: "Export", + onClick: (table) => + exportTableToExcel({ + ...exportConfig, + tableInstance: table ?? getTableInstance?.(), + }), + }, + { + key: "add-task", + type: "button", + icon: , + label: "Create Task List", + variant: "default", + className: "text-primary-foreground", + onClick: () => navigate(`/admin/taskList/create`), + }, + { + key: "toggle-archived-task", + type: "button", + icon: , + label: showArchived ? "Active Task Lists" : "Archived Task List", + variant: "secondary", + className: "border border-border", + onClick: () => navigate("/admin/taskList/archived"), + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/pages/task_list/ArchiveTaskList.jsx b/src/modules/admin/pages/task_list/ArchiveTaskList.jsx new file mode 100644 index 0000000..94a1afd --- /dev/null +++ b/src/modules/admin/pages/task_list/ArchiveTaskList.jsx @@ -0,0 +1,24 @@ +import { House } from "lucide-react"; +import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; +import ArchiveTaskListTable from "@/modules/admin/components/task/ArchiveTaskListTable"; + +export default function ArchiveTaskList() { + const items = [ + { label: "Home", icon: , to: "/admin" }, + { label: "Task List", to: "/admin/taskList" }, + { label: "Archived" }, + ]; + + return ( +
+
+
+ +
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/modules/admin/pages/task_list/CreateTaskList.jsx b/src/modules/admin/pages/task_list/CreateTaskList.jsx new file mode 100644 index 0000000..2af96bc --- /dev/null +++ b/src/modules/admin/pages/task_list/CreateTaskList.jsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useAdminTask } from '@/contexts/AdminTaskContext'; +import GroupMultiSelect from '@/components/generic/GroupMultiSelect'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +export default function CreateTaskList() { + const navigate = useNavigate(); + const { createTaskList, assignGroups, loading } = useAdminTask(); + + const [form, setForm] = useState({ name: '', description: '' }); + const [selectedGroupIds, setSelectedGroupIds] = useState([]); + const [errors, setErrors] = useState({}); + + const validate = () => { + const e = {}; + if (!form.name.trim()) e.name = 'Task list name is required.'; + setErrors(e); + return Object.keys(e).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!validate()) return; + + const created = await createTaskList({ + name: form.name.trim(), + description: form.description.trim() || null, + }); + + if (!created) return; // createTaskList already toasts on error + + // Assign selected groups if any — non-blocking: navigate regardless + if (selectedGroupIds.length > 0) { + await assignGroups(created.task_list_id, selectedGroupIds); + } + }; + + return ( +
+ + + Create Task List + + +
+ + {/* Name */} +
+ + setForm({ ...form, name: e.target.value })} + placeholder="e.g. Onboarding Tasks" + /> + {errors.name && ( +

{errors.name}

+ )} +
+ + {/* Description */} +
+ +