From b342de2913525b3b099626d01ab11f1ae5dc2a06 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sat, 20 Jun 2026 21:14:49 +0200 Subject: [PATCH] feat(panel): show + filter tasks by project and product The task list had no way to see or filter by which project/product a task belongs to. Add a "Project / Product" column to the table (resolving the id to a name, with a "(product)" hint for fan-out tasks) and Project + Product multi-select filters alongside Status/Team/Type, URL-backed and client-side like the others. Options and names come from the projects/products lists. --- panel/src/app/(dashboard)/tasks/page.tsx | 60 +++++++- panel/src/components/tasks/task-filters.tsx | 155 +++++++++++++++++++- panel/src/components/tasks/task-table.tsx | 21 ++- 3 files changed, 232 insertions(+), 4 deletions(-) diff --git a/panel/src/app/(dashboard)/tasks/page.tsx b/panel/src/app/(dashboard)/tasks/page.tsx index 4a447d33..c6b07f6a 100644 --- a/panel/src/app/(dashboard)/tasks/page.tsx +++ b/panel/src/app/(dashboard)/tasks/page.tsx @@ -3,6 +3,8 @@ import { Suspense, useMemo, useCallback } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { useTasks } from "@/hooks/use-tasks"; +import { useProjects } from "@/hooks/use-projects"; +import { useProducts } from "@/hooks/use-products"; import { TaskStatus, Team, TaskType } from "@/types"; import { OfflineState } from "@/components/ui/offline-state"; import { CreateTaskDialog, TaskFilters, TaskTable, SortField, SortDirection } from "@/components/tasks"; @@ -31,6 +33,16 @@ function TasksPageContent() { () => (taskTypeParam?.split(",").filter(Boolean) as TaskType[]) || [], [taskTypeParam] ); + const projectParam = searchParams.get("project"); + const projectFilter = useMemo( + () => projectParam?.split(",").filter(Boolean) || [], + [projectParam] + ); + const productParam = searchParams.get("product"); + const productFilter = useMemo( + () => productParam?.split(",").filter(Boolean) || [], + [productParam] + ); // Table state from URL const sortField = (searchParams.get("sortBy") as SortField) || "created_at"; @@ -73,6 +85,14 @@ function TasksPageContent() { updateParams({ type: value.length > 0 ? value.join(",") : null }); }, [updateParams]); + const handleProjectChange = useCallback((value: string[]) => { + updateParams({ project: value.length > 0 ? value.join(",") : null }); + }, [updateParams]); + + const handleProductChange = useCallback((value: string[]) => { + updateParams({ product: value.length > 0 ? value.join(",") : null }); + }, [updateParams]); + // Table state handlers const handleSortChange = useCallback((field: SortField, direction: SortDirection | null) => { if (direction === null) { @@ -101,6 +121,26 @@ function TasksPageContent() { // Fetch all tasks and filter client-side for multi-select const { data: tasks, isLoading, error, refetch } = useTasks(); + // Projects + products: power the Project/Product filter options + name display. + const { data: projects } = useProjects(); + const { data: products } = useProducts(); + const projectNames = useMemo( + () => Object.fromEntries((projects ?? []).map((p) => [p.id, p.name])), + [projects] + ); + const productNames = useMemo( + () => Object.fromEntries((products ?? []).map((p) => [p.id, p.name])), + [products] + ); + const projectOptions = useMemo( + () => (projects ?? []).map((p) => ({ value: p.id, label: p.name })), + [projects] + ); + const productOptions = useMemo( + () => (products ?? []).map((p) => ({ value: p.id, label: p.name })), + [products] + ); + // Filter tasks based on multi-select filters const filteredTasks = useMemo(() => { if (!tasks) return []; @@ -127,9 +167,19 @@ function TasksPageContent() { return false; } + // Project filter (a task with no project_id is excluded when filtering by project) + if (projectFilter.length > 0 && (!task.project_id || !projectFilter.includes(task.project_id))) { + return false; + } + + // Product filter (a task with no product_id is excluded when filtering by product) + if (productFilter.length > 0 && (!task.product_id || !productFilter.includes(task.product_id))) { + return false; + } + return true; }); - }, [tasks, searchQuery, statusFilter, teamFilter, taskTypeFilter]); + }, [tasks, searchQuery, statusFilter, teamFilter, taskTypeFilter, projectFilter, productFilter]); // Check if it's a connection error (backend not running) const isOffline = error && ( @@ -168,6 +218,12 @@ function TasksPageContent() { onTeamChange={handleTeamChange} taskTypeFilter={taskTypeFilter} onTaskTypeChange={handleTaskTypeChange} + projectFilter={projectFilter} + onProjectChange={handleProjectChange} + projectOptions={projectOptions} + productFilter={productFilter} + onProductChange={handleProductChange} + productOptions={productOptions} /> @@ -182,6 +238,8 @@ function TasksPageContent() { void; + // Optional project / product filters (dynamic options from the API) + projectFilter?: string[]; + onProjectChange?: (value: string[]) => void; + projectOptions?: { value: string; label: string }[]; + productFilter?: string[]; + onProductChange?: (value: string[]) => void; + productOptions?: { value: string; label: string }[]; } const STATUS_LABELS: Record = { @@ -70,6 +77,12 @@ export function TaskFilters({ onTeamChange, taskTypeFilter = [], onTaskTypeChange, + projectFilter = [], + onProjectChange, + projectOptions = [], + productFilter = [], + onProductChange, + productOptions = [], }: TaskFiltersProps) { const toggleStatus = (status: TaskStatus) => { if (statusFilter.includes(status)) { @@ -96,9 +109,33 @@ export function TaskFilters({ } }; + const toggleProject = (id: string) => { + if (!onProjectChange) return; + onProjectChange( + projectFilter.includes(id) + ? projectFilter.filter((p) => p !== id) + : [...projectFilter, id] + ); + }; + + const toggleProduct = (id: string) => { + if (!onProductChange) return; + onProductChange( + productFilter.includes(id) + ? productFilter.filter((p) => p !== id) + : [...productFilter, id] + ); + }; + const clearStatuses = () => onStatusChange([]); const clearTeams = () => onTeamChange([]); const clearTaskTypes = () => onTaskTypeChange?.([]); + const clearProjects = () => onProjectChange?.([]); + const clearProducts = () => onProductChange?.([]); + const projectLabel = (id: string) => + projectOptions.find((o) => o.value === id)?.label ?? id; + const productLabel = (id: string) => + productOptions.find((o) => o.value === id)?.label ?? id; return ( @@ -248,11 +285,105 @@ export function TaskFilters({ )} + + {/* Project Multi-Select (optional) */} + {onProjectChange && projectOptions.length > 0 && ( + + + + + +
+ Project + {projectFilter.length > 0 && ( + + )} +
+
+ {projectOptions.map((opt) => ( + + ))} +
+
+
+ )} + + {/* Product Multi-Select (optional) */} + {onProductChange && productOptions.length > 0 && ( + + + + + +
+ Product + {productFilter.length > 0 && ( + + )} +
+
+ {productOptions.map((opt) => ( + + ))} +
+
+
+ )} {/* Active Filters */} - {(statusFilter.length > 0 || teamFilter.length > 0 || taskTypeFilter.length > 0) && ( + {(statusFilter.length > 0 || teamFilter.length > 0 || taskTypeFilter.length > 0 || projectFilter.length > 0 || productFilter.length > 0) && (
{statusFilter.map((status) => ( @@ -281,7 +412,25 @@ export function TaskFilters({ /> ))} - {(statusFilter.length > 0 || teamFilter.length > 0 || taskTypeFilter.length > 0) && ( + {projectFilter.map((id) => ( + + {projectLabel(id)} + toggleProject(id)} + /> + + ))} + {productFilter.map((id) => ( + + {productLabel(id)} + toggleProduct(id)} + /> + + ))} + {(statusFilter.length > 0 || teamFilter.length > 0 || taskTypeFilter.length > 0 || projectFilter.length > 0 || productFilter.length > 0) && (