feat: TaskListView orchestrator (toolbar + table/grid + pager)
This commit is contained in:
197
src/renderer/components/tasks/TaskListView.tsx
Normal file
197
src/renderer/components/tasks/TaskListView.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Search, List, LayoutGrid, ClipboardCheck } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
|
||||
import { TaskTable } from './TaskTable';
|
||||
import { TaskCard } from './TaskCard';
|
||||
import { TaskPager } from './TaskPager';
|
||||
import { TaskDetailSheet } from './TaskDetailSheet';
|
||||
import { NewTaskDialog } from './NewTaskDialog';
|
||||
import { EditTaskDialog } from './EditTaskDialog';
|
||||
import { type TaskItem } from './TaskRow';
|
||||
|
||||
type StatusFilter = 'active' | 'todo' | 'in_progress' | 'all' | 'done';
|
||||
type OrderBy = 'dueDate' | 'priority' | 'createdAt';
|
||||
|
||||
const PAGE_SIZE_KEY = 'tasksPageSize';
|
||||
const VIEW_MODE_KEY = 'tasksViewMode';
|
||||
|
||||
function readPageSize(): number {
|
||||
const v = Number(localStorage.getItem(PAGE_SIZE_KEY));
|
||||
return [10, 25, 50, 100].includes(v) ? v : 25;
|
||||
}
|
||||
|
||||
function readViewMode(): 'list' | 'grid' {
|
||||
return (localStorage.getItem(VIEW_MODE_KEY) as 'list' | 'grid') ?? 'list';
|
||||
}
|
||||
|
||||
export function TaskListView({
|
||||
projectId,
|
||||
hideProjectColumn,
|
||||
}: {
|
||||
projectId?: string;
|
||||
hideProjectColumn?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const utils = trpc.useUtils();
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('active');
|
||||
const [orderBy, setOrderBy] = useState<OrderBy>('dueDate');
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid'>(readViewMode);
|
||||
const [pageSize, setPageSize] = useState<number>(readPageSize);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [editTask, setEditTask] = useState<TaskItem | null>(null);
|
||||
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
|
||||
|
||||
useEffect(() => { localStorage.setItem(VIEW_MODE_KEY, viewMode); }, [viewMode]);
|
||||
useEffect(() => { localStorage.setItem(PAGE_SIZE_KEY, String(pageSize)); }, [pageSize]);
|
||||
|
||||
// Reset page on any filter change
|
||||
useEffect(() => { setPageIndex(0); }, [debouncedSearch, statusFilter, orderBy]);
|
||||
|
||||
// Search debounce
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [search]);
|
||||
|
||||
const backendStatus = statusFilter === 'todo' || statusFilter === 'in_progress' || statusFilter === 'done'
|
||||
? statusFilter : undefined;
|
||||
|
||||
const queryInput = useMemo(() => ({
|
||||
...(backendStatus ? { status: backendStatus } : {}),
|
||||
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
orderBy,
|
||||
}), [backendStatus, debouncedSearch, orderBy, projectId]);
|
||||
|
||||
const { data: filteredTasks } = trpc.tasks.list.useQuery(queryInput);
|
||||
|
||||
const updateTask = trpc.tasks.update.useMutation({
|
||||
onSuccess: () => void utils.tasks.list.invalidate(),
|
||||
onError: (err) => notifyError('toast.task.updateError', err),
|
||||
});
|
||||
const deleteTask = trpc.tasks.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
notify('warning', 'toast.task.deleted');
|
||||
void utils.tasks.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.task.deleteError', err),
|
||||
});
|
||||
|
||||
const tasksAll = (filteredTasks ?? [])
|
||||
.filter((task) => statusFilter !== 'active' || task.status === 'todo' || task.status === 'in_progress');
|
||||
|
||||
const total = tasksAll.length;
|
||||
const lastPage = Math.max(0, Math.ceil(total / pageSize) - 1);
|
||||
const safePageIndex = Math.min(pageIndex, lastPage);
|
||||
if (safePageIndex !== pageIndex) setPageIndex(safePageIndex);
|
||||
const pageTasks = tasksAll.slice(safePageIndex * pageSize, (safePageIndex + 1) * pageSize);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="active">{t('tasks.active')}</TabsTrigger>
|
||||
<TabsTrigger value="todo">{t('tasks.toDo')}</TabsTrigger>
|
||||
<TabsTrigger value="in_progress">{t('tasks.inProgress')}</TabsTrigger>
|
||||
<TabsTrigger value="done">{t('tasks.done')}</TabsTrigger>
|
||||
<TabsTrigger value="all">{t('tasks.all')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<InputGroup className="w-56">
|
||||
<InputGroupAddon><Search /></InputGroupAddon>
|
||||
<InputGroupInput placeholder={t('tasks.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</InputGroup>
|
||||
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dueDate">{t('tasks.orderByDue')}</SelectItem>
|
||||
<SelectItem value="priority">{t('tasks.orderByPriority')}</SelectItem>
|
||||
<SelectItem value="createdAt">{t('tasks.orderByCreated')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ToggleGroup type="single" value={viewMode} onValueChange={(v) => v && setViewMode(v as 'list' | 'grid')} variant="outline" size="sm">
|
||||
<ToggleGroupItem value="list" aria-label={t('tasks.viewList')}><List /></ToggleGroupItem>
|
||||
<ToggleGroupItem value="grid" aria-label={t('tasks.viewGrid')}><LayoutGrid /></ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Button size="sm" onClick={() => setNewOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />{t('tasks.newTask')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{total === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon"><ClipboardCheck /></EmptyMedia>
|
||||
<EmptyTitle>{t('tasks.noTasksFound')}</EmptyTitle>
|
||||
<EmptyDescription>{t('tasks.noTasksDescription')}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{pageTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onToggle={(id, status) => {
|
||||
const next = status === 'todo' ? 'in_progress' : status === 'in_progress' ? 'done' : 'todo';
|
||||
updateTask.mutate({ id, status: next });
|
||||
}}
|
||||
onEdit={setEditTask}
|
||||
onDelete={(id) => deleteTask.mutate({ id })}
|
||||
onClick={setViewTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<TaskTable
|
||||
tasks={pageTasks}
|
||||
hideProjectColumn={hideProjectColumn}
|
||||
onRowClick={setViewTask}
|
||||
onEdit={setEditTask}
|
||||
onDelete={(id) => deleteTask.mutate({ id })}
|
||||
onStatusChange={(id, status) => updateTask.mutate({ id, status })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pager (always visible when there are tasks) */}
|
||||
{total > 0 && (
|
||||
<TaskPager
|
||||
total={total}
|
||||
pageIndex={safePageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={(s) => { setPageSize(s); setPageIndex(0); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NewTaskDialog open={newOpen} onOpenChange={setNewOpen} defaultProjectId={projectId} />
|
||||
<EditTaskDialog task={editTask} open={!!editTask} onOpenChange={(o) => { if (!o) setEditTask(null); }} />
|
||||
<TaskDetailSheet
|
||||
task={viewTask}
|
||||
open={!!viewTask}
|
||||
onOpenChange={(o) => { if (!o) setViewTask(null); }}
|
||||
onEdit={(task) => { setViewTask(null); setEditTask(task); }}
|
||||
onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user