feat: TaskListView orchestrator (toolbar + table/grid + pager)

This commit is contained in:
Roberto
2026-05-08 14:30:29 +02:00
parent ef04bec66f
commit 50d01c7aec

View 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>
);
}