mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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=""
|
|
||||||
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 */}
|
className=""
|
||||||
<div className="rounded-lg border border-border bg-card text-card-foreground shadow-sm">
|
onClick={() => navigate('/admin/taskList')}
|
||||||
<div className="p-6 space-y-6">
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
|
||||||
{/* Assigned Groups */}
|
</Button>
|
||||||
<div className="space-y-4">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
<h1 className="text-xl font-semibold">{taskList.name}</h1>
|
||||||
<Equal className="size-4" />
|
{taskList.description && (
|
||||||
Assigned Groups
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{taskList.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</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>
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ export const AdminRoutes = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
// Task
|
// Task
|
||||||
{
|
{
|
||||||
path: 'taskList',
|
path: 'taskList',
|
||||||
|
|||||||
Reference in New Issue
Block a user