add: layout

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-05-21 11:48:04 +08:00
parent 8454d8bcf3
commit 5241f0f1dd
13 changed files with 657 additions and 507 deletions
+24 -2
View File
@@ -369,6 +369,28 @@ export function AdminTaskProvider({ children }) {
[request] [request]
); );
// ─── GET TASK LIST FIELD VALUES ───────────────────────────────────────────────
const fetchTaskListFieldValues = useCallback(
(field) =>
request(async () => {
const res = await api.get(`${BASE}/field-values`, { params: { field } });
return res.data?.data ?? [];
}),
[request]
);
// ─── GET TASK FIELD VALUES ────────────────────────────────────────────────────
const fetchTaskFieldValues = useCallback(
(taskListId, field) =>
request(async () => {
const res = await api.get(`${BASE}/${taskListId}/tasks/field-values`, {
params: { field },
});
return res.data?.data ?? [];
}),
[request]
);
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
return ( return (
<AdminTaskContext.Provider value={{ <AdminTaskContext.Provider value={{
@@ -383,7 +405,7 @@ export function AdminTaskProvider({ children }) {
// task list actions // task list actions
fetchTaskLists, fetchTaskList, fetchArchivedTaskLists, fetchTaskLists, fetchTaskList, fetchArchivedTaskLists,
createTaskList, updateTaskList, createTaskList, updateTaskList,
archiveTaskList, restoreTaskList, archiveTaskList, restoreTaskList, fetchTaskListFieldValues,
bulkArchiveTaskLists, bulkRestoreTaskLists, bulkArchiveTaskLists, bulkRestoreTaskLists,
// task list group actions // task list group actions
@@ -393,7 +415,7 @@ export function AdminTaskProvider({ children }) {
// task actions // task actions
fetchTasks, fetchTask, fetchArchivedTasks, fetchTasks, fetchTask, fetchArchivedTasks,
createTask, updateTask, createTask, updateTask, fetchTaskFieldValues,
archiveTask, restoreTask, archiveTask, restoreTask,
bulkArchiveTasks, bulkRestoreTasks, bulkArchiveTasks, bulkRestoreTasks,
}}> }}>
@@ -14,12 +14,23 @@ import { buildRowActions } from "@/modules/admin/config/task_list/task/archive/r
import { getTimestamp } from "@/utils/timestamp.util"; import { getTimestamp } from "@/utils/timestamp.util";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { formatDate } from "@/utils/table.util";
import { ListTodo, Users } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
export default function ArchivedTaskTable() { export default function ArchivedTaskTable() {
const navigate = useNavigate(); const navigate = useNavigate();
const { taskListId } = useParams(); const { taskListId } = useParams();
const { const {
tasks, attributes, pagination, loading, taskList, tasks, attributes, pagination, loading,
fetchArchivedTasks, fetchArchivedTasks,
restoreTask, restoreTask,
bulkRestoreTasks, bulkRestoreTasks,
@@ -27,11 +38,12 @@ export default function ArchivedTaskTable() {
const [restoreTarget, setRestoreTarget] = useState(null); const [restoreTarget, setRestoreTarget] = useState(null);
const [restoreIds, setRestoreIds] = useState(null); const [restoreIds, setRestoreIds] = useState(null);
const [showGroupsDialog, setShowGroupsDialog] = useState(false);
const tableRefsRef = useRef({ const tableRefsRef = useRef({
getFilters: () => [], getFilters: () => [],
getSort: () => [], getSort: () => [],
resetSelection: () => {}, resetSelection: () => { },
tableInstance: null, tableInstance: null,
}); });
@@ -91,8 +103,77 @@ export default function ArchivedTaskTable() {
[attributes, rowActions] [attributes, rowActions]
); );
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) : '—';
return ( return (
<> <>
{/* ── Detail card ───────────────────────────────────────────────── */}
<div className="bg-card border rounded-xl p-5 flex flex-col gap-4 w-full mb-4">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1 min-w-0">
{taskList
? <h1 className="text-lg font-medium leading-none">{taskList.name}</h1>
: <Skeleton className="h-5 w-48" />
}
{taskList
? <p className="text-sm text-muted-foreground mt-1">{taskList.description ?? '—'}</p>
: <Skeleton className="h-4 w-72 mt-1" />
}
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<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>
<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>
) : <span className="text-sm font-medium">0</span>
) : <Skeleton className="h-4 w-8 inline-block" />}
</div>
<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>
<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>
<DataTable <DataTable
title="Archived Tasks" title="Archived Tasks"
data={tasks} data={tasks}
@@ -142,6 +223,28 @@ export default function ArchivedTaskTable() {
loading={loading} loading={loading}
onSuccess={handleSuccess} onSuccess={handleSuccess}
/> />
<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>
</> </>
); );
} }
@@ -20,7 +20,7 @@ export default function TaskListTable() {
const { const {
taskLists, attributes, pagination, loading, taskLists, attributes, pagination, loading,
fetchTaskLists, fetchArchivedTaskLists, fetchTaskLists, fetchArchivedTaskLists, fetchTaskListFieldValues,
archiveTaskList, restoreTaskList, archiveTaskList, restoreTaskList,
bulkArchiveTaskLists, bulkRestoreTaskLists, bulkArchiveTaskLists, bulkRestoreTaskLists,
} = useAdminTask(); } = useAdminTask();
@@ -124,7 +124,7 @@ export default function TaskListTable() {
pagination={pagination} pagination={pagination}
loading={loading} loading={loading}
onFetch={fetchTaskLists} onFetch={fetchTaskLists}
onFetchFilterData={() => []} onFetchFilterData={fetchTaskListFieldValues}
onRefsReady={handleRefsReady} onRefsReady={handleRefsReady}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet <FilterSheet
@@ -2,12 +2,12 @@ import { RotateCcw, Eye } from "lucide-react";
export function buildRowActions({ navigate, onRestore }) { export function buildRowActions({ navigate, onRestore }) {
return [ return [
{ // {
key: "view", // key: "view",
label: "View", // label: "View",
icon: <Eye className="size-4" />, // icon: <Eye className="size-4" />,
onClick: (row) => navigate(`/admin/taskList/${row.task_list_id}/view`), // onClick: (row) => navigate(`/admin/taskList/${row.task_list_id}/view`),
}, // },
{ {
key: "restore", key: "restore",
label: "Restore", label: "Restore",
@@ -5,7 +5,7 @@ import { exportTableToExcel } from "@/utils/excel.util";
/** /**
* @param {Object} deps * @param {Object} deps
* @param {Function} deps.fetchTasks * @param {Function} deps.fetchTasks
* @param {string} deps.taskListId * @param {string} deps.taskId
* @param {Object} deps.pagination * @param {Object} deps.pagination
* @param {Object} deps.exportConfig * @param {Object} deps.exportConfig
* @param {Function} deps.navigate * @param {Function} deps.navigate
@@ -17,7 +17,7 @@ import { exportTableToExcel } from "@/utils/excel.util";
*/ */
export function buildToolbarActions({ export function buildToolbarActions({
fetchTasks, fetchTasks,
taskListId, taskId,
pagination, pagination,
exportConfig, exportConfig,
navigate, navigate,
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ArrowLeft } from 'lucide-react';
export default function CreateTaskList() { export default function CreateTaskList() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -43,76 +44,84 @@ export default function CreateTaskList() {
}; };
return ( return (
<div className="max-w-xl mx-auto py-8 px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<Card> <div className="mx-auto">
<CardHeader> <div className="flex items-center gap-3 mb-6">
<CardTitle>Create Task List</CardTitle> <Button type="button" variant="ghost" size="icon" onClick={() => navigate('/admin/taskList')}>
</CardHeader> <ArrowLeft className="h-4 w-4" />
<CardContent> </Button>
<form onSubmit={handleSubmit} className="space-y-4"> <div>
<h1 className="text-xl font-semibold">Create Task List</h1>
<p className="text-sm text-muted-foreground">View course information.</p>
</div>
</div>
<Card className="lg:w-2xl">
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */} {/* Name */}
<div className="space-y-1"> <div className="space-y-3">
<Label htmlFor="name">Name *</Label> <Label htmlFor="name">Name *</Label>
<Input <Input
id="name" id="name"
value={form.name} value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="e.g. Onboarding Tasks" placeholder="e.g. Onboarding Tasks"
/> />
{errors.name && ( {errors.name && (
<p className="text-xs text-destructive">{errors.name}</p> <p className="text-xs text-destructive">{errors.name}</p>
)} )}
</div> </div>
{/* Description */} {/* Description */}
<div className="space-y-1"> <div className="space-y-3">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">Description</Label>
<Textarea <Textarea
id="description" id="description"
value={form.description} value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })} onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Optional description" placeholder="Optional description"
rows={3} rows={3}
/> />
</div> </div>
{/* Groups */} {/* Groups */}
<div className="space-y-1"> <div className="space-y-3">
<Label> <Label>
Assign to Groups Assign to Groups
<span className="ml-1.5 text-xs text-muted-foreground font-normal"> <span className="ml-1.5 text-xs text-muted-foreground font-normal">
(optional) (optional)
</span> </span>
</Label> </Label>
<GroupMultiSelect <GroupMultiSelect
value={selectedGroupIds} value={selectedGroupIds}
onChange={setSelectedGroupIds} onChange={setSelectedGroupIds}
disabled={loading} disabled={loading}
placeholder="Select groups to assign…" placeholder="Select groups to assign…"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Members of selected groups will be able to see and complete this task list. Members of selected groups will be able to see and complete this task list.
</p> </p>
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex gap-2 justify-end pt-2"> <div className="flex gap-2 justify-end pt-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => navigate('/admin/taskList')} onClick={() => navigate('/admin/taskList')}
> >
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={loading}> <Button type="submit" disabled={loading}>
{loading ? 'Creating…' : 'Create Task List'} {loading ? 'Creating…' : 'Create Task List'}
</Button> </Button>
</div> </div>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div >
); );
} }
@@ -78,11 +78,11 @@ export default function EditTaskList() {
if (!updated) return; if (!updated) return;
// ── 2. Diff groups ──────────────────────────────────────────────────── // ── 2. Diff groups ────────────────────────────────────────────────────
const toAssign = selectedGroupIds.filter((id) => !originalGroupIds.includes(id)); const toAssign = selectedGroupIds.filter((id) => !originalGroupIds.includes(id));
const toUnassign = originalGroupIds.filter((id) => !selectedGroupIds.includes(id)); const toUnassign = originalGroupIds.filter((id) => !selectedGroupIds.includes(id));
await Promise.all([ await Promise.all([
toAssign.length ? assignGroups(taskListId, toAssign) : Promise.resolve(), toAssign.length ? assignGroups(taskListId, toAssign) : Promise.resolve(),
toUnassign.length ? unassignGroups(taskListId, toUnassign) : Promise.resolve(), toUnassign.length ? unassignGroups(taskListId, toUnassign) : Promise.resolve(),
]); ]);
@@ -100,87 +100,89 @@ export default function EditTaskList() {
); );
return ( return (
<div className="max-w-xl mx-auto py-8 px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="flex gap-2 items-center mb-4"> <div className="mx-auto">
<Button <div className="flex gap-2 items-center mb-4">
variant="ghost" <Button
onClick={() => navigate('/admin/taskList')} variant="ghost"
> onClick={() => navigate(`/admin/taskList`)}
<ArrowLeft className="size-4" /> >
</Button> <ArrowLeft className="size-4" />
<div className="space-y-1"> </Button>
<h1 className="font-medium">Edit Task List</h1> <div className="space-y-1">
<p className="text-sm text-muted-foreground">Update task list.</p> <h1 className="text-xl font-semibold">Edit Task List</h1>
<p className="text-sm text-muted-foreground">Update task list.</p>
</div>
</div> </div>
<Card className="lg:w-2xl">
<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> </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> </div>
); );
} }
+127 -122
View File
@@ -20,11 +20,11 @@ import {
// ─── All styling uses shadcn tokens — only label/icon differs per type // ─── All styling uses shadcn tokens — only label/icon differs per type
const REQUIREMENT_CONFIG = { const REQUIREMENT_CONFIG = {
visit_link: { label: 'Visit Link', badgeLabel: 'Link', Icon: Link2 }, visit_link: { label: 'Visit Link', badgeLabel: 'Link', Icon: Link2 },
upload_file: { label: 'Upload File', badgeLabel: 'Upload', Icon: Upload }, upload_file: { label: 'Upload File', badgeLabel: 'Upload', Icon: Upload },
read_course: { label: 'Read Course', badgeLabel: 'Course', Icon: BookOpen }, read_course: { label: 'Read Course', badgeLabel: 'Course', Icon: BookOpen },
read_unit: { label: 'Read Unit', badgeLabel: 'Unit', Icon: BookMarked }, read_unit: { label: 'Read Unit', badgeLabel: 'Unit', Icon: BookMarked },
read_lesson: { label: 'Read Lesson', badgeLabel: 'Lesson', Icon: FileCheck2 }, read_lesson: { label: 'Read Lesson', badgeLabel: 'Lesson', Icon: FileCheck2 },
}; };
// ─── Label / value row — fully themed by shadcn tokens ─────────────────────── // ─── Label / value row — fully themed by shadcn tokens ───────────────────────
@@ -156,141 +156,146 @@ export default function ViewTaskList() {
); );
const groups = taskList.groups ?? []; const groups = taskList.groups ?? [];
const tasks = taskList.tasks ?? []; const tasks = taskList.tasks ?? [];
return ( return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="mx-auto space-y-4">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Button <Button
variant="ghost" variant="ghost"
className="" className=""
onClick={() => navigate('/admin/taskList')} onClick={() => navigate('/admin/taskList')}
> >
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
</Button> </Button>
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-xl font-semibold">{taskList.name}</h1> <h1 className="text-xl font-semibold">{taskList.name}</h1>
{taskList.description && ( {taskList.description && (
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
{taskList.description} {taskList.description}
</p> </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> </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> </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>
{/* Tasks */} <div className="lg:w-2xl rounded-lg border border-border bg-card text-card-foreground shadow-sm">
<div className="space-y-4"> <div className="p-6 space-y-6">
<div className="flex items-center gap-1.5 text-sm font-medium">
<ClipboardList className="h-4 w-4 text-muted-foreground" /> {/* Assigned Groups */}
Tasks <div className="space-y-4">
{tasks.length > 0 && ( <div className="flex items-center gap-1.5 text-sm font-medium">
<span className="ml-auto text-xs text-muted-foreground font-normal"> <Equal className="size-4" />
{tasks.length} task{tasks.length !== 1 ? 's' : ''} Assigned Groups
</span> </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> </div>
{tasks.length > 0 ? ( {/* Tasks */}
<Accordion type="multiple" className="rounded-md border divide-y"> <div className="space-y-4">
{tasks.map((task, index) => ( <div className="flex items-center gap-1.5 text-sm font-medium">
<AccordionItem <ClipboardList className="h-4 w-4 text-muted-foreground" />
key={task.task_id ?? index} Tasks
value={String(task.task_id ?? index)} {tasks.length > 0 && (
className="px-3 border-0 border-b last:border-b-0" <span className="ml-auto text-xs text-muted-foreground font-normal">
> {tasks.length} task{tasks.length !== 1 ? 's' : ''}
<AccordionTrigger className="py-2.5 hover:no-underline gap-3 [&>svg]:shrink-0"> </span>
<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"> </div>
{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"> {tasks.length > 0 ? (
<div className="ml-8 space-y-4 text-sm"> <Accordion
type="multiple"
className="rounded-md border divide-y"
defaultValue={[String(tasks[0]?.task_id ?? 0)]}
>
{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>
{task.description && ( <AccordionContent className="pb-4 pt-0">
<div className="flex items-center gap-2 text-muted-foreground"> <div className="ml-8 space-y-4 text-sm">
<FileText className="size-4 shrink-0" />
<p className="leading-relaxed">{task.description}</p>
</div>
)}
{task.deadline && ( {task.description && (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<CalendarClock className="size-4 shrink-0" /> <FileText className="size-4 shrink-0" />
<span className="text-sm"> <p className="leading-relaxed">{task.description}</p>
Deadline:{' '} </div>
<span className="text-foreground font-medium"> )}
{new Date(task.deadline).toLocaleDateString(undefined, {
year: 'numeric', {task.deadline && (
month: 'short', <div className="flex items-center gap-2 text-muted-foreground">
day: 'numeric', <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> </span>
</span> </div>
</div> )}
)}
<TaskRequirementsSection requirements={task.requirements} /> <TaskRequirementsSection requirements={task.requirements} />
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
) : (
<p className="text-sm text-muted-foreground">No tasks in this list yet.</p>
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
) : (
<p className="text-sm text-muted-foreground">No tasks in this list yet.</p>
)}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -50,79 +50,81 @@ export default function CreateTask() {
}; };
return ( return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-6"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="flex items-center gap-3"> <div className="mx-auto space-y-6">
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/taskList/${taskListId}/tasks`)}> <div className="flex items-center gap-3">
<ArrowLeft className="h-4 w-4" /> <Button variant="ghost" size="icon" onClick={() => navigate(`/admin/taskList/${taskListId}/tasks`)}>
</Button> <ArrowLeft className="h-4 w-4" />
<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> </Button>
<h1 className="text-xl font-semibold">Create Task</h1>
</div> </div>
</form>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Task info */}
<Card className="lg:w-2xl">
<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>
</div> </div>
); );
} }
@@ -75,92 +75,94 @@ export default function EditTask() {
); );
return ( return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-6"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="flex items-center gap-3"> <div className="mx-auto space-y-6">
{/** /admin/taskList/${taskListId}/${taskId}/tasks */} <div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}> {/** /admin/taskList/${taskListId}/tasks */}
<ArrowLeft className="h-4 w-4" /> <Button variant="ghost" size="icon" onClick={() => navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/view`)}>
</Button> <ArrowLeft className="h-4 w-4" />
<h1 className="text-xl font-semibold">Edit Task</h1> </Button>
</div> <h1 className="text-xl font-semibold">Edit Task</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<Card> <Card className="lg:w-2xl">
<CardHeader><CardTitle className="text-base">Task Details</CardTitle></CardHeader> <CardHeader><CardTitle className="text-base">Task Details</CardTitle></CardHeader>
<CardContent className="space-y-4"> <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"> <div className="space-y-1">
<Label htmlFor="deadline">Deadline</Label> <Label htmlFor="name">Name *</Label>
<Input <Input
id="deadline" id="name"
type="datetime-local" value={form.name}
value={form.deadline} onChange={(e) => setForm({ ...form, name: e.target.value })}
onChange={(e) => setForm({ ...form, deadline: 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>
<div className="space-y-1">
<Label>Status</Label> <div className="grid grid-cols-2 gap-4">
<Select <div className="space-y-1">
value={form.status} <Label htmlFor="deadline">Deadline</Label>
onValueChange={(v) => setForm({ ...form, status: v })} <Input
> id="deadline"
<SelectTrigger> type="datetime-local"
<SelectValue /> value={form.deadline}
</SelectTrigger> onChange={(e) => setForm({ ...form, deadline: e.target.value })}
<SelectContent> />
{STATUS_OPTIONS.map((s) => ( </div>
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem> <div className="space-y-1">
))} <Label>Status</Label>
</SelectContent> <Select
</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> </div>
</div> </CardContent>
</CardContent> </Card>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Requirements</CardTitle> <CardTitle className="text-base">Requirements</CardTitle>
<p className="text-sm text-muted-foreground">Changes here will replace existing requirements.</p> <p className="text-sm text-muted-foreground">Changes here will replace existing requirements.</p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<RequirementBuilder <RequirementBuilder
value={form.requirements} value={form.requirements}
onChange={(reqs) => setForm({ ...form, requirements: reqs })} onChange={(reqs) => setForm({ ...form, requirements: reqs })}
/> />
</CardContent> </CardContent>
</Card> </Card>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
{/** /admin/taskList/${taskListId}/${taskId}/tasks */} {/** /admin/taskList/${taskListId}/tasks */}
<Button type="button" variant="outline" onClick={() => navigate(-1)}> <Button type="button" variant="outline" onClick={() => navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/view`)}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={loading}> <Button type="submit" disabled={loading}>
{loading ? 'Saving…' : 'Save Changes'} {loading ? 'Saving…' : 'Save Changes'}
</Button> </Button>
</div> </div>
</form> </form>
</div>
</div> </div>
); );
} }
@@ -31,7 +31,7 @@ export default function Tasks() {
const { const {
taskList, tasks, attributes, pagination, loading, taskList, tasks, attributes, pagination, loading,
fetchTaskList, fetchTasks, fetchArchivedTasks, fetchTaskList, fetchTasks, fetchArchivedTasks,
archiveTask, restoreTask, archiveTask, restoreTask, fetchTaskFieldValues,
bulkArchiveTasks, bulkRestoreTasks, bulkArchiveTasks, bulkRestoreTasks,
} = useAdminTask(); } = useAdminTask();
@@ -114,7 +114,7 @@ export default function Tasks() {
const breadcrumbs = [ const breadcrumbs = [
{ label: 'Home', icon: <House className="size-4" />, to: '/admin' }, { label: 'Home', icon: <House className="size-4" />, to: '/admin' },
{ label: 'Task List', to: '/admin/taskList' }, { label: 'Task List', to: '/admin/taskList' },
{ label: 'View Tasks' }, { label: 'Tasks' },
]; ];
return ( return (
@@ -214,6 +214,7 @@ export default function Tasks() {
pagination={pagination} pagination={pagination}
loading={loading} loading={loading}
onFetch={handleFetch} onFetch={handleFetch}
onFetchFilterData={fetchTaskFieldValues}
toolbarActions={toolbarActions} toolbarActions={toolbarActions}
selectionActions={selectionActions} selectionActions={selectionActions}
columnPinning={columnPinning} columnPinning={columnPinning}
@@ -150,91 +150,93 @@ export default function ViewTask() {
const requirements = task.requirements ?? []; const requirements = task.requirements ?? [];
return ( return (
<div className="max-w-2xl mx-auto p-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
{/* Header */} <div className="mx-auto p-4">
<div className="flex items-center justify-between mb-4"> {/* Header */}
<div className="flex gap-2 items-center"> <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 <Button
variant="ghost" variant="outline"
onClick={() => navigate(`/admin/taskList/${taskListId}/tasks`)} size="sm"
className="shrink-0 gap-1.5"
onClick={() => navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/edit`)}
> >
<ArrowLeft className="size-4" /> <Pencil className="h-3.5 w-3.5" />
Edit
</Button> </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> </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 */} {/* 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="lg:w-2xl rounded-lg border border-border bg-card text-card-foreground shadow-sm">
<div className="p-4 space-y-2"> <div className="p-4 space-y-2">
{/* Description */} {/* Description */}
{task.description ? ( {task.description ? (
<div className="flex gap-2 text-muted-foreground"> <div className="flex gap-2 text-muted-foreground">
<FileText className="h-4 w-4 mt-0.5 shrink-0" /> <FileText className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm leading-relaxed">{task.description}</p> <p className="text-sm leading-relaxed">{task.description}</p>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground italic">No description provided.</p> <p className="text-sm text-muted-foreground italic">No description provided.</p>
)} )}
{/* Deadline */} {/* Deadline */}
{task.deadline && ( {task.deadline && (
<> <>
<Separator /> <Separator />
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<CalendarClock className="size-4 shrink-0" /> <CalendarClock className="size-4 shrink-0" />
<span className="text-sm"> <span className="text-sm">
Deadline:{' '} Deadline:{' '}
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">
{new Date(task.deadline).toLocaleDateString(undefined, { {new Date(task.deadline).toLocaleDateString(undefined, {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
})} })}
</span>
</span> </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> </div>
<Badge variant="outline" className="capitalize"> </>
{task.status} )}
</Badge>
{/* 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> </div>
</> <TaskRequirementsSection requirements={requirements} />
)}
<Separator />
{/* Requirements */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium">
<GitPullRequest className="size-4" /> Requirements
</div> </div>
<TaskRequirementsSection requirements={requirements} />
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+2
View File
@@ -158,6 +158,8 @@ export const AdminRoutes = {
}, },
] ]
}, },
// Task // Task
{ {
path: 'taskList', path: 'taskList',