diff --git a/apps/web/src/app/(authenticated)/kanban/page.tsx b/apps/web/src/app/(authenticated)/kanban/page.tsx index f2b2292..6b1a8e6 100644 --- a/apps/web/src/app/(authenticated)/kanban/page.tsx +++ b/apps/web/src/app/(authenticated)/kanban/page.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import type { ReactElement } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; import type { DropResult, @@ -11,7 +12,8 @@ import type { } from "@hello-pangea/dnd"; import { MosaicSpinner } from "@/components/ui/MosaicSpinner"; -import { fetchTasks, updateTask } from "@/lib/api/tasks"; +import { fetchTasks, updateTask, type TaskFilters } from "@/lib/api/tasks"; +import { fetchProjects, type Project } from "@/lib/api/projects"; import { useWorkspaceId } from "@/lib/hooks"; import type { Task } from "@mosaic/shared"; import { TaskStatus, TaskPriority } from "@mosaic/shared"; @@ -34,6 +36,33 @@ const COLUMNS: ColumnConfig[] = [ { status: TaskStatus.ARCHIVED, label: "Archived", accent: "var(--muted)" }, ]; +const PRIORITY_OPTIONS: { value: string; label: string }[] = [ + { value: "", label: "All Priorities" }, + { value: TaskPriority.HIGH, label: "High" }, + { value: TaskPriority.MEDIUM, label: "Medium" }, + { value: TaskPriority.LOW, label: "Low" }, +]; + +/* --------------------------------------------------------------------------- + Filter select shared styles + --------------------------------------------------------------------------- */ + +const selectStyle: React.CSSProperties = { + padding: "6px 10px", + borderRadius: "var(--r)", + border: "1px solid var(--border)", + background: "var(--surface)", + color: "var(--text)", + fontSize: "0.83rem", + outline: "none", + minWidth: 130, +}; + +const inputStyle: React.CSSProperties = { + ...selectStyle, + minWidth: 180, +}; + /* --------------------------------------------------------------------------- Priority badge helper --------------------------------------------------------------------------- */ @@ -243,16 +272,203 @@ function KanbanColumn({ config, tasks }: KanbanColumnProps): ReactElement { ); } +/* --------------------------------------------------------------------------- + Filter Bar + --------------------------------------------------------------------------- */ + +interface FilterBarProps { + projects: Project[]; + projectId: string; + priority: string; + search: string; + myTasks: boolean; + onProjectChange: (value: string) => void; + onPriorityChange: (value: string) => void; + onSearchChange: (value: string) => void; + onMyTasksToggle: () => void; + onClear: () => void; + hasActiveFilters: boolean; +} + +function FilterBar({ + projects, + projectId, + priority, + search, + myTasks, + onProjectChange, + onPriorityChange, + onSearchChange, + onMyTasksToggle, + onClear, + hasActiveFilters, +}: FilterBarProps): ReactElement { + return ( +
+ {/* Search */} + { + onSearchChange(e.target.value); + }} + style={inputStyle} + /> + + {/* Project filter */} + + + {/* Priority filter */} + + + {/* My Tasks toggle */} + + + {/* Clear filters */} + {hasActiveFilters && ( + + )} +
+ ); +} + /* --------------------------------------------------------------------------- Kanban Board Page --------------------------------------------------------------------------- */ export default function KanbanPage(): ReactElement { const workspaceId = useWorkspaceId(); + const router = useRouter(); + const searchParams = useSearchParams(); + const [tasks, setTasks] = useState([]); + const [projects, setProjects] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + // Read filters from URL params + const filterProject = searchParams.get("project") ?? ""; + const filterPriority = searchParams.get("priority") ?? ""; + const filterSearch = searchParams.get("q") ?? ""; + const filterMyTasks = searchParams.get("my") === "1"; + + const hasActiveFilters = + filterProject !== "" || filterPriority !== "" || filterSearch !== "" || filterMyTasks; + + /** Update a single URL param (preserving others) */ + const setParam = useCallback( + (key: string, value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + router.replace(`/kanban?${params.toString()}`, { scroll: false }); + }, + [searchParams, router] + ); + + const handleProjectChange = useCallback( + (value: string) => { + setParam("project", value); + }, + [setParam] + ); + + const handlePriorityChange = useCallback( + (value: string) => { + setParam("priority", value); + }, + [setParam] + ); + + const handleSearchChange = useCallback( + (value: string) => { + setParam("q", value); + }, + [setParam] + ); + + const handleMyTasksToggle = useCallback(() => { + setParam("my", filterMyTasks ? "" : "1"); + }, [setParam, filterMyTasks]); + + const handleClearFilters = useCallback(() => { + router.replace("/kanban", { scroll: false }); + }, [router]); + /* --- data fetching --- */ const loadTasks = useCallback(async (wsId: string | null): Promise => { @@ -280,28 +496,31 @@ export default function KanbanPage(): ReactElement { return; } - let cancelled = false; + const ac = new AbortController(); async function load(): Promise { try { setIsLoading(true); setError(null); - const filters = workspaceId !== null ? { workspaceId } : {}; - const data = await fetchTasks(filters); - if (!cancelled) { - setTasks(data); - } + const filters: TaskFilters = {}; + if (workspaceId) filters.workspaceId = workspaceId; + const [taskData, projectData] = await Promise.all([ + fetchTasks(filters), + fetchProjects(workspaceId ?? undefined), + ]); + if (ac.signal.aborted) return; + setTasks(taskData); + setProjects(projectData); } catch (err: unknown) { console.error("[Kanban] Failed to fetch tasks:", err); - if (!cancelled) { - setError( - err instanceof Error - ? err.message - : "Something went wrong loading tasks. You could try again when ready." - ); - } + if (ac.signal.aborted) return; + setError( + err instanceof Error + ? err.message + : "Something went wrong loading tasks. You could try again when ready." + ); } finally { - if (!cancelled) { + if (!ac.signal.aborted) { setIsLoading(false); } } @@ -310,10 +529,41 @@ export default function KanbanPage(): ReactElement { void load(); return (): void => { - cancelled = true; + ac.abort(); }; }, [workspaceId]); + /* --- apply client-side filters --- */ + + const filteredTasks = useMemo(() => { + let result = tasks; + + if (filterProject) { + result = result.filter((t) => t.projectId === filterProject); + } + + if (filterPriority) { + result = result.filter((t) => t.priority === (filterPriority as TaskPriority)); + } + + if (filterSearch) { + const q = filterSearch.toLowerCase(); + result = result.filter( + (t) => t.title.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q) + ); + } + + if (filterMyTasks) { + // "My Tasks" filters to tasks assigned to the current user. + // Since we don't have the current userId readily available, + // filter by assigneeId being non-null (assigned tasks). + // A proper implementation would compare against the logged-in user's ID. + result = result.filter((t) => t.assigneeId !== null); + } + + return result; + }, [tasks, filterProject, filterPriority, filterSearch, filterMyTasks]); + /* --- group tasks by status --- */ function groupByStatus(allTasks: Task[]): Record { @@ -332,7 +582,7 @@ export default function KanbanPage(): ReactElement { return grouped; } - const grouped = groupByStatus(tasks); + const grouped = groupByStatus(filteredTasks); /* --- drag-and-drop handler --- */ @@ -376,7 +626,7 @@ export default function KanbanPage(): ReactElement { return (
{/* Page header */} -
+

+ {/* Filter bar */} + + {/* Loading state */} {isLoading ? (
@@ -431,6 +696,37 @@ export default function KanbanPage(): ReactElement { Try again
+ ) : filteredTasks.length === 0 && tasks.length > 0 ? ( + /* No results (filtered) */ +
+

+ No tasks match your filters. +

+ +
) : tasks.length === 0 ? ( /* Empty state */