This commit is contained in:
rgrgogu
2026-05-20 13:26:38 +08:00
36 changed files with 3751 additions and 2 deletions
+122
View File
@@ -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 (
<div className="flex flex-wrap items-end gap-3">
{/* ── Date picker ──────────────────────────────────────────────── */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Date</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled}
className="w-36 justify-between font-normal text-sm"
>
{selectedDate ? format(selectedDate, 'MMM d, yyyy') : 'Select date'}
<ChevronDownIcon className="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={selectedDate}
captionLayout="dropdown"
defaultMonth={selectedDate}
onSelect={handleDateSelect}
/>
</PopoverContent>
</Popover>
</div>
{/* ── Time input ───────────────────────────────────────────────── */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Time</Label>
<Input
type="time"
value={timeStr}
onChange={handleTimeChange}
disabled={disabled || !selectedDate}
step="60"
className="w-28 appearance-none bg-background
[&::-webkit-calendar-picker-indicator]:hidden
[&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
{/* ── Clear ────────────────────────────────────────────────────── */}
{value && (
<Button
type="button"
variant="outline"
disabled={disabled}
onClick={handleClear}
>
Clear
</Button>
)}
</div>
);
}
+268
View File
@@ -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(
<div
ref={dropdownRef}
style={dropdownStyle}
className="rounded-md border border-border bg-popover shadow-lg"
>
{/* Search row */}
<div className="p-2 border-b border-border">
<Input
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search groups…"
className="h-8 text-sm"
/>
</div>
{/* Select all / Clear row — only when groups are loaded */}
{!loadingGroups && allGroups.length > 0 && (
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/40">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={allFilteredSelected ? handleClear : handleSelectAll}
className="text-xs text-primary hover:underline underline-offset-2 font-medium"
>
{allFilteredSelected ? 'Deselect all' : 'Select all'}
{search && ` (${filteredIds.length})`}
</button>
{value.length > 0 && (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={handleClear}
className="text-xs text-muted-foreground hover:text-destructive transition-colors"
>
Clear ({value.length})
</button>
)}
</div>
)}
{/* Options */}
<ul className="max-h-52 overflow-y-auto py-1">
{loadingGroups ? (
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
Loading groups…
</li>
) : filtered.length === 0 ? (
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
No groups found.
</li>
) : (
filtered.map((g) => {
const selected = value.includes(g.group_id);
return (
<li
key={g.group_id}
onMouseDown={(e) => 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'
)}
>
<div className={cn(
'h-4 w-4 rounded border flex items-center justify-center shrink-0',
selected
? 'bg-primary border-primary text-primary-foreground'
: 'border-input'
)}>
{selected && <Check className="h-3 w-3" />}
</div>
<Users className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="truncate">{g.name}</span>
</li>
);
})
)}
</ul>
</div>,
document.body
);
return (
<>
{/* ── Trigger button ───────────────────────────────────────────── */}
<button
ref={triggerRef}
type="button"
disabled={disabled}
onClick={() => { setOpen((o) => !o); setSearch(''); }}
className={cn(
'w-full min-h-9 px-3 py-1.5 rounded-md border border-input bg-background text-sm',
'flex items-center gap-1.5 text-left',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
'disabled:opacity-50 disabled:cursor-not-allowed',
open && 'ring-2 ring-ring ring-offset-1'
)}
>
{selectedGroups.length === 0 ? (
<span className="text-muted-foreground flex-1">{placeholder}</span>
) : (
<span className="flex items-center gap-1 flex-1 min-w-0">
{/* Always show only the first selected group */}
<Badge variant="secondary" className="gap-1 text-xs pr-1 shrink-0">
<Users className="h-3 w-3" />
{selectedGroups[0].name}
<span
role="button"
tabIndex={0}
onClick={(e) => remove(e, selectedGroups[0].group_id)}
onKeyDown={(e) => e.key === 'Enter' && remove(e, selectedGroups[0].group_id)}
className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5 cursor-pointer"
>
<X className="h-2.5 w-2.5" />
</span>
</Badge>
{/* +N overflow chip */}
{overflowCount > 0 && (
<Badge variant="outline" className="text-xs px-1.5 shrink-0">
+{overflowCount}
</Badge>
)}
</span>
)}
<ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground shrink-0 ml-auto" />
</button>
{/* ── Portalled dropdown ────────────────────────────────────────── */}
{dropdown}
</>
);
}
+403
View File
@@ -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 (
<AdminTaskContext.Provider value={{
// state
taskLists, taskList,
tasks, task,
taskListGroups, setTaskListGroups,
attributes, setAttributes,
pagination, setPagination,
loading,
// task list actions
fetchTaskLists, fetchTaskList, fetchArchivedTaskLists,
createTaskList, updateTaskList,
archiveTaskList, restoreTaskList,
bulkArchiveTaskLists, bulkRestoreTaskLists,
// task list group actions
fetchTaskListGroups,
assignGroups,
unassignGroups,
// task actions
fetchTasks, fetchTask, fetchArchivedTasks,
createTask, updateTask,
archiveTask, restoreTask,
bulkArchiveTasks, bulkRestoreTasks,
}}>
{children}
</AdminTaskContext.Provider>
);
}
+3
View File
@@ -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 }) => {
<UserProvider>
<UserGroupProvider>
<CoursesProvider>
<AdminTaskProvider>
{children}
</AdminTaskProvider>
</CoursesProvider>
</UserGroupProvider>
</UserProvider>
+1 -1
View File
@@ -27,7 +27,7 @@ export const ADMIN_SECTIONS = [
description: "Manage tasks and courses",
tiles: [
{ key: "courses", label: "Courses", icon: BookText, link: "/admin/courses" },
{ key: "tasks", label: "Tasks", icon: ListCheck, link: "" },
{ key: "tasks", label: "Tasks", icon: ListCheck, link: "/admin/taskList" },
],
},
{
@@ -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>
);
}
+37
View File
@@ -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
]
},