diff --git a/apps/web/app/api/domains/[domainId]/tasks/bulk/route.ts b/apps/web/app/api/domains/[domainId]/tasks/bulk/route.ts index 2f40214..b368630 100644 --- a/apps/web/app/api/domains/[domainId]/tasks/bulk/route.ts +++ b/apps/web/app/api/domains/[domainId]/tasks/bulk/route.ts @@ -21,6 +21,10 @@ const bulkUpdateSchema = z.object({ }), }); +const bulkDeleteSchema = z.object({ + ids: z.array(z.string().uuid()).min(1).max(200), +}); + type RouteContext = { params: Promise<{ domainId: string }> }; // POST /api/domains/[domainId]/tasks/bulk — Bulk update tasks (order, status) @@ -77,3 +81,51 @@ export const POST = withAuth(async (request: NextRequest, user, co return createErrorResponse('INTERNAL_ERROR', 'Failed to bulk update tasks', 500); } }); + +// DELETE /api/domains/[domainId]/tasks/bulk — Bulk soft-delete tasks +export const DELETE = withAuth(async (request: NextRequest, user, context) => { + const { domainId } = await context!.params; + await requireWorkspaceAccess(domainId); + + try { + const body = await request.json(); + const data = bulkDeleteSchema.parse(body); + + // Verify all tasks belong to this domain + const existingTasks = await db.select({ id: tasks.id, title: tasks.title }) + .from(tasks) + .where(and(inArray(tasks.id, data.ids), eq(tasks.domainId, domainId), isNull(tasks.deletedAt))); + + if (existingTasks.length !== data.ids.length) { + return createErrorResponse('NOT_FOUND', 'One or more tasks not found', 404); + } + + // Soft delete + await db.update(tasks) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(inArray(tasks.id, data.ids)); + + // Record activity for each task + for (const task of existingTasks) { + await recordActivity({ + actor: user.name, + action: 'bulk_deleted', + entityType: 'task', + entityId: task.id, + changes: { title: task.title }, + workspaceId: domainId, + }); + } + + return NextResponse.json({ deleted: existingTasks.length }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + if (error instanceof ApiError) { + return createErrorResponse(error.code, error.message, error.status); + } + console.error('[tasks bulk DELETE] error:', error); + return createErrorResponse('INTERNAL_ERROR', 'Failed to bulk delete tasks', 500); + } +}); diff --git a/apps/web/components/tasks/tasks-kanban-view.tsx b/apps/web/components/tasks/tasks-kanban-view.tsx index 5906265..f982d8e 100644 --- a/apps/web/components/tasks/tasks-kanban-view.tsx +++ b/apps/web/components/tasks/tasks-kanban-view.tsx @@ -13,10 +13,17 @@ import { import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Calendar, GripVertical, Plus } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Calendar, GripVertical, Link2, Loader2, Plus } from 'lucide-react'; import { toast } from 'sonner'; import { TaskDetailPanel } from './task-detail-panel'; import { useRealtimeContext } from '@/components/realtime-provider'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; interface Task { id: string; @@ -59,15 +66,15 @@ function TaskCard({ task, domainName, domainColor, + depCount, onClick, }: { task: Task; domainName: string; domainColor: string | null; + depCount: number; onClick: () => void; }) { - const subtaskCount = 0; // Will be populated from API - return ( @@ -75,6 +82,20 @@ function TaskCard({ {task.title} + {depCount > 0 && ( + + + + + + + + +

{depCount} {depCount === 1 ? 'dependency' : 'dependencies'}

+
+
+
+ )}
; + depCounts: Map; onTaskClick: (task: Task) => void; - onAddTask: () => void; + inlineInput: string; + onInlineInputChange: (value: string) => void; + onInlineSubmit: () => void; + inlineLoading: boolean; }) { return (
@@ -147,15 +176,6 @@ function DroppableColumn({

{title}

({tasks.length})
-
onTaskClick(task)} /> )) )}
+ {/* Inline task creation input */} +
+ onInlineInputChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !inlineLoading) { + onInlineSubmit(); + } + }} + className="h-8 text-sm" + disabled={inlineLoading} + /> + +
); } @@ -193,6 +237,9 @@ export function TasksKanbanView({ const [domainMap, setDomainMap] = useState>(new Map()); const [loading, setLoading] = useState(true); const [selectedTask, setSelectedTask] = useState(null); + const [inlineInputs, setInlineInputs] = useState>({}); + const [inlineLoading, setInlineLoading] = useState>({}); + const [depCounts, setDepCounts] = useState>(new Map()); const { subscribe } = useRealtimeContext(); const sensors = useSensors( @@ -248,6 +295,37 @@ export function TasksKanbanView({ return unsubscribe; }, [domainId, subscribe, fetchTasks, onRefresh]); + // Fetch dependency counts for visible tasks + useEffect(() => { + if (tasks.length === 0 || !domainId) return; + const controller = new AbortController(); + let cancelled = false; + + (async () => { + const counts = new Map(); + // Fetch task details in parallel to get dependency counts + const results = await Promise.allSettled( + tasks.map((t) => + fetch(`/api/domains/${domainId}/tasks/${t.id}`, { signal: controller.signal }) + .then((r) => (r.ok ? r.json() : null)) + ) + ); + if (cancelled) return; + for (let i = 0; i < results.length; i++) { + const r = results[i]; + if (r.status === 'fulfilled' && r.value?.dependencies?.length > 0) { + counts.set(tasks[i].id, r.value.dependencies.length); + } + } + setDepCounts(counts); + })(); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [tasks, domainId]); + async function handleDragEnd(event: DragEndEvent) { const { active, over } = event; if (!over) return; @@ -279,11 +357,30 @@ export function TasksKanbanView({ } } - function handleAddTask(status: string) { - // Open create dialog with pre-filled status - document.dispatchEvent( - new CustomEvent('open-create-task', { detail: { status, domainId } }) - ); + async function handleInlineCreate(status: string) { + const title = inlineInputs[status]?.trim(); + if (!title) return; + + setInlineLoading((prev) => ({ ...prev, [status]: true })); + + try { + const response = await fetch(`/api/domains/${domainId}/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, status }), + }); + if (!response.ok) throw new Error('Unable to create task'); + const newTask = await response.json(); + // Prepend the new task to the list + setTasks((prev) => [{ ...newTask, tags: [] }, ...prev]); + setInlineInputs((prev) => ({ ...prev, [status]: '' })); + toast.success(`Created "${title}"`); + } catch (error) { + console.error('Failed to create task:', error); + toast.error('Unable to create task'); + } finally { + setInlineLoading((prev) => ({ ...prev, [status]: false })); + } } if (loading) { @@ -301,8 +398,14 @@ export function TasksKanbanView({ color={column.color} tasks={tasks.filter((t) => t.status === column.id)} domainMap={domainMap} + depCounts={depCounts} onTaskClick={setSelectedTask} - onAddTask={() => handleAddTask(column.id)} + inlineInput={inlineInputs[column.id] || ''} + onInlineInputChange={(value) => + setInlineInputs((prev) => ({ ...prev, [column.id]: value })) + } + onInlineSubmit={() => handleInlineCreate(column.id)} + inlineLoading={inlineLoading[column.id] || false} /> ))} diff --git a/apps/web/components/tasks/tasks-list-view.tsx b/apps/web/components/tasks/tasks-list-view.tsx index 1df4f1f..1220cb5 100644 --- a/apps/web/components/tasks/tasks-list-view.tsx +++ b/apps/web/components/tasks/tasks-list-view.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useMemo } from 'react'; +import { useRouter, useSearchParams, usePathname } from 'next/navigation'; import { Table, TableBody, @@ -12,8 +13,19 @@ import { import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; -import { Calendar, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'; +import { + Calendar, + MoreHorizontal, + Pencil, + Trash2, + ArrowUpDown, + ArrowUp, + ArrowDown, + Search, + X, +} from 'lucide-react'; import { toast } from 'sonner'; import { TaskDetailPanel } from './task-detail-panel'; import { @@ -32,6 +44,18 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; import { useRealtimeContext } from '@/components/realtime-provider'; interface Task { @@ -54,6 +78,89 @@ const priorityColors: Record = { low: 'secondary', }; +const statusOptions = ['todo', 'in_progress', 'done', 'cancelled'] as const; +const priorityOptions = ['low', 'medium', 'high', 'urgent'] as const; + +const sortableColumns = [ + { key: 'title', label: 'Task' }, + { key: 'status', label: 'Status' }, + { key: 'priority', label: 'Priority' }, + { key: 'due_date', label: 'Due Date' }, +] as const; + +function useDebounce(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + return debounced; +} + +function MultiSelectFilter({ + label, + options, + selected, + onChange, +}: { + label: string; + options: readonly string[]; + selected: string[]; + onChange: (values: string[]) => void; +}) { + const [open, setOpen] = useState(false); + + return ( + + + + + +
+ {options.map((opt) => { + const checked = selected.includes(opt); + return ( + + ); + })} +
+ {selected.length > 0 && ( + + )} +
+
+ ); +} + export function TasksListView({ domainId, onRefresh, @@ -61,6 +168,10 @@ export function TasksListView({ domainId: string; onRefresh?: () => void; }) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [selectedTask, setSelectedTask] = useState(null); @@ -69,10 +180,59 @@ export function TasksListView({ const [domainMap, setDomainMap] = useState>(new Map()); const { subscribe } = useRealtimeContext(); + // Filter/sort state from URL params + const statusFilter = searchParams.get('status')?.split(',').filter(Boolean) || []; + const priorityFilter = searchParams.get('priority')?.split(',').filter(Boolean) || []; + const domainFilter = searchParams.get('domain') || ''; + const searchQuery = searchParams.get('search') || ''; + const sort = searchParams.get('sort') || 'order'; + const order = searchParams.get('order') || 'asc'; + + // Local search input (debounced) + const [searchInput, setSearchInput] = useState(searchQuery); + const debouncedSearch = useDebounce(searchInput, 300); + + // Bulk selection + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [bulkStatusOpen, setBulkStatusOpen] = useState(false); + const [bulkPriorityOpen, setBulkPriorityOpen] = useState(false); + const [bulkDeleting, setBulkDeleting] = useState(false); + + // Sync debounced search to URL + useEffect(() => { + if (debouncedSearch !== searchQuery) { + const params = new URLSearchParams(searchParams.toString()); + if (debouncedSearch) { + params.set('search', debouncedSearch); + } else { + params.delete('search'); + } + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + } + }, [debouncedSearch, searchQuery, searchParams, pathname, router]); + + const updateURLParam = useCallback( + (key: string, value: string | null) => { + const params = new URLSearchParams(searchParams.toString()); + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + }, + [searchParams, pathname, router] + ); + const fetchTasks = useCallback(async () => { if (!domainId) return; try { - const response = await fetch(`/api/domains/${domainId}/tasks?sort=order&limit=200`); + const params = new URLSearchParams({ sort, order, limit: '200' }); + if (statusFilter.length > 0) params.set('status', statusFilter.join(',')); + if (priorityFilter.length > 0) params.set('priority', priorityFilter.join(',')); + if (searchQuery) params.set('search', searchQuery); + + const response = await fetch(`/api/domains/${domainId}/tasks?${params.toString()}`); if (!response.ok) throw new Error('Unable to load tasks'); const data = await response.json(); setTasks(data.items || []); @@ -82,7 +242,7 @@ export function TasksListView({ } finally { setLoading(false); } - }, [domainId]); + }, [domainId, sort, order, statusFilter, priorityFilter, searchQuery]); const fetchDomains = useCallback(async () => { try { @@ -115,6 +275,11 @@ export function TasksListView({ return unsubscribe; }, [domainId, subscribe, fetchTasks, onRefresh]); + // Clear selection when tasks change + useEffect(() => { + setSelectedIds(new Set()); + }, [tasks]); + async function toggleTaskComplete(task: Task) { const newStatus = task.status === 'done' ? 'todo' : 'done'; try { @@ -134,110 +299,329 @@ export function TasksListView({ } } + function toggleSort(column: string) { + if (sort === column) { + updateURLParam('order', order === 'asc' ? 'desc' : 'asc'); + } else { + updateURLParam('sort', column); + updateURLParam('order', 'asc'); + } + } + + function getSortIcon(column: string) { + if (sort !== column) return ; + return order === 'asc' ? ( + + ) : ( + + ); + } + + // Bulk actions + const allSelected = tasks.length > 0 && selectedIds.size === tasks.length; + + function toggleSelectAll() { + if (allSelected) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(tasks.map((t) => t.id))); + } + } + + function toggleSelect(id: string) { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + async function handleBulkStatus(newStatus: string) { + if (selectedIds.size === 0) return; + setBulkStatusOpen(false); + try { + const res = await fetch(`/api/domains/${domainId}/tasks/bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ids: Array.from(selectedIds), + updates: { status: newStatus }, + }), + }); + if (!res.ok) throw new Error(); + toast.success(`Marked ${selectedIds.size} tasks as ${newStatus.replace('_', ' ')}`); + setSelectedIds(new Set()); + fetchTasks(); + } catch { + toast.error('Unable to update tasks'); + } + } + + async function handleBulkPriority(newPriority: string) { + if (selectedIds.size === 0) return; + setBulkPriorityOpen(false); + try { + const res = await fetch(`/api/domains/${domainId}/tasks/bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ids: Array.from(selectedIds), + updates: { priority: newPriority }, + }), + }); + if (!res.ok) throw new Error(); + toast.success(`Set priority for ${selectedIds.size} tasks`); + setSelectedIds(new Set()); + fetchTasks(); + } catch { + toast.error('Unable to update tasks'); + } + } + + async function handleBulkDelete() { + if (selectedIds.size === 0) return; + setBulkDeleting(true); + try { + const res = await fetch(`/api/domains/${domainId}/tasks/bulk`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: Array.from(selectedIds) }), + }); + if (!res.ok) throw new Error(); + toast.success(`Deleted ${selectedIds.size} tasks`); + setSelectedIds(new Set()); + fetchTasks(); + } catch { + toast.error('Unable to delete tasks'); + } finally { + setBulkDeleting(false); + } + } + if (loading) { return

Loading tasks...

; } - if (tasks.length === 0) { - return ( -
-

No tasks yet. Create your first task to get started!

-
- ); - } - return ( <> - -
- - - - - Task - Status - Priority - Domain - Due Date - - - - - {tasks.map((task) => ( - - - toggleTaskComplete(task)} - aria-label={`Mark "${task.title}" as ${task.status === 'done' ? 'incomplete' : 'complete'}`} - /> - - - - - - - {task.status.replace('_', ' ')} - - - - - {task.priority} - - - - {domainMap.get(task.domainId) || task.domainId.slice(0, 8)} - - - {task.dueDate && ( - - - {new Date(task.dueDate).toLocaleDateString()} - - )} - - - - - - - - setSelectedTask(task)}> - - Edit - - setDeleteId(task.id)}> - - Delete - - - - - - ))} - -
+ {/* Filter bar */} +
+ + updateURLParam('status', values.length > 0 ? values.join(',') : null) + } + /> + + updateURLParam('priority', values.length > 0 ? values.join(',') : null) + } + /> + +
+ + setSearchInput(e.target.value)} + className="h-9 pl-8 pr-8" + /> + {searchInput && ( + + )}
- - +
+ + {/* Bulk action bar */} + {selectedIds.size > 0 && ( +
+ + {selectedIds.size} selected + +
+ + + + + + + {statusOptions.map((s) => ( + handleBulkStatus(s)}> + {s.replace('_', ' ')} + + ))} + + + + + + + + {priorityOptions.map((p) => ( + handleBulkPriority(p)}> + {p} + + ))} + + + +
+
+ )} + + {tasks.length === 0 ? ( +
+

No tasks yet. Create your first task to get started!

+
+ ) : ( + +
+ + + + + + + {sortableColumns.map((col) => ( + toggleSort(col.key)} + > + + {col.label} + {getSortIcon(col.key)} + + + ))} + Domain + + + + + {tasks.map((task) => ( + + + toggleSelect(task.id)} + aria-label={`Select "${task.title}"`} + /> + + + + + + + {task.status.replace('_', ' ')} + + + + + {task.priority} + + + + {task.dueDate && ( + + + {new Date(task.dueDate).toLocaleDateString()} + + )} + + + {domainMap.get(task.domainId) || task.domainId.slice(0, 8)} + + + + + + + + setSelectedTask(task)}> + + Edit + + setDeleteId(task.id)}> + + Delete + + + + + + ))} + +
+
+ +
+ )} !o && setDeleteId(null)}>