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.
This commit is contained in:
Renn F
2026-06-20 21:14:49 +02:00
parent 818333f626
commit b342de2913
3 changed files with 232 additions and 4 deletions
+59 -1
View File
@@ -3,6 +3,8 @@
import { Suspense, useMemo, useCallback } from "react"; import { Suspense, useMemo, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { useTasks } from "@/hooks/use-tasks"; 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 { TaskStatus, Team, TaskType } from "@/types";
import { OfflineState } from "@/components/ui/offline-state"; import { OfflineState } from "@/components/ui/offline-state";
import { CreateTaskDialog, TaskFilters, TaskTable, SortField, SortDirection } from "@/components/tasks"; import { CreateTaskDialog, TaskFilters, TaskTable, SortField, SortDirection } from "@/components/tasks";
@@ -31,6 +33,16 @@ function TasksPageContent() {
() => (taskTypeParam?.split(",").filter(Boolean) as TaskType[]) || [], () => (taskTypeParam?.split(",").filter(Boolean) as TaskType[]) || [],
[taskTypeParam] [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 // Table state from URL
const sortField = (searchParams.get("sortBy") as SortField) || "created_at"; const sortField = (searchParams.get("sortBy") as SortField) || "created_at";
@@ -73,6 +85,14 @@ function TasksPageContent() {
updateParams({ type: value.length > 0 ? value.join(",") : null }); updateParams({ type: value.length > 0 ? value.join(",") : null });
}, [updateParams]); }, [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 // Table state handlers
const handleSortChange = useCallback((field: SortField, direction: SortDirection | null) => { const handleSortChange = useCallback((field: SortField, direction: SortDirection | null) => {
if (direction === null) { if (direction === null) {
@@ -101,6 +121,26 @@ function TasksPageContent() {
// Fetch all tasks and filter client-side for multi-select // Fetch all tasks and filter client-side for multi-select
const { data: tasks, isLoading, error, refetch } = useTasks(); 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 // Filter tasks based on multi-select filters
const filteredTasks = useMemo(() => { const filteredTasks = useMemo(() => {
if (!tasks) return []; if (!tasks) return [];
@@ -127,9 +167,19 @@ function TasksPageContent() {
return false; 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; return true;
}); });
}, [tasks, searchQuery, statusFilter, teamFilter, taskTypeFilter]); }, [tasks, searchQuery, statusFilter, teamFilter, taskTypeFilter, projectFilter, productFilter]);
// Check if it's a connection error (backend not running) // Check if it's a connection error (backend not running)
const isOffline = error && ( const isOffline = error && (
@@ -168,6 +218,12 @@ function TasksPageContent() {
onTeamChange={handleTeamChange} onTeamChange={handleTeamChange}
taskTypeFilter={taskTypeFilter} taskTypeFilter={taskTypeFilter}
onTaskTypeChange={handleTaskTypeChange} onTaskTypeChange={handleTaskTypeChange}
projectFilter={projectFilter}
onProjectChange={handleProjectChange}
projectOptions={projectOptions}
productFilter={productFilter}
onProductChange={handleProductChange}
productOptions={productOptions}
/> />
</div> </div>
@@ -182,6 +238,8 @@ function TasksPageContent() {
<TaskTable <TaskTable
tasks={filteredTasks} tasks={filteredTasks}
isLoading={isLoading} isLoading={isLoading}
projectNames={projectNames}
productNames={productNames}
sortField={sortField} sortField={sortField}
sortDirection={sortDir} sortDirection={sortDir}
onSortChange={handleSortChange} onSortChange={handleSortChange}
+153 -2
View File
@@ -23,6 +23,13 @@ interface TaskFiltersProps {
// Optional new filters // Optional new filters
taskTypeFilter?: TaskType[]; taskTypeFilter?: TaskType[];
onTaskTypeChange?: (value: TaskType[]) => void; onTaskTypeChange?: (value: TaskType[]) => 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<TaskStatus, string> = { const STATUS_LABELS: Record<TaskStatus, string> = {
@@ -70,6 +77,12 @@ export function TaskFilters({
onTeamChange, onTeamChange,
taskTypeFilter = [], taskTypeFilter = [],
onTaskTypeChange, onTaskTypeChange,
projectFilter = [],
onProjectChange,
projectOptions = [],
productFilter = [],
onProductChange,
productOptions = [],
}: TaskFiltersProps) { }: TaskFiltersProps) {
const toggleStatus = (status: TaskStatus) => { const toggleStatus = (status: TaskStatus) => {
if (statusFilter.includes(status)) { 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 clearStatuses = () => onStatusChange([]);
const clearTeams = () => onTeamChange([]); const clearTeams = () => onTeamChange([]);
const clearTaskTypes = () => onTaskTypeChange?.([]); 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 ( return (
<Card> <Card>
@@ -248,11 +285,105 @@ export function TaskFilters({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
)} )}
{/* Project Multi-Select (optional) */}
{onProjectChange && projectOptions.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="min-w-32 justify-between">
<span className="truncate">
{projectFilter.length === 0
? "All Projects"
: projectFilter.length === 1
? projectLabel(projectFilter[0])
: `${projectFilter.length} projects`}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="start">
<div className="flex items-center justify-between mb-2 pb-2 border-b">
<span className="text-sm font-medium">Project</span>
{projectFilter.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={clearProjects}
>
Clear
</Button>
)}
</div>
<div className="space-y-1 max-h-64 overflow-y-auto">
{projectOptions.map((opt) => (
<label
key={opt.value}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
>
<Checkbox
checked={projectFilter.includes(opt.value)}
onCheckedChange={() => toggleProject(opt.value)}
/>
<span className="text-sm">{opt.label}</span>
</label>
))}
</div>
</PopoverContent>
</Popover>
)}
{/* Product Multi-Select (optional) */}
{onProductChange && productOptions.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="min-w-32 justify-between">
<span className="truncate">
{productFilter.length === 0
? "All Products"
: productFilter.length === 1
? productLabel(productFilter[0])
: `${productFilter.length} products`}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="start">
<div className="flex items-center justify-between mb-2 pb-2 border-b">
<span className="text-sm font-medium">Product</span>
{productFilter.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={clearProducts}
>
Clear
</Button>
)}
</div>
<div className="space-y-1 max-h-64 overflow-y-auto">
{productOptions.map((opt) => (
<label
key={opt.value}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
>
<Checkbox
checked={productFilter.includes(opt.value)}
onCheckedChange={() => toggleProduct(opt.value)}
/>
<span className="text-sm">{opt.label}</span>
</label>
))}
</div>
</PopoverContent>
</Popover>
)}
</div> </div>
</div> </div>
{/* Active Filters */} {/* 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) && (
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t"> <div className="flex flex-wrap gap-2 mt-3 pt-3 border-t">
{statusFilter.map((status) => ( {statusFilter.map((status) => (
<Badge key={status} variant="secondary" className="gap-1"> <Badge key={status} variant="secondary" className="gap-1">
@@ -281,7 +412,25 @@ export function TaskFilters({
/> />
</Badge> </Badge>
))} ))}
{(statusFilter.length > 0 || teamFilter.length > 0 || taskTypeFilter.length > 0) && ( {projectFilter.map((id) => (
<Badge key={id} variant="secondary" className="gap-1">
{projectLabel(id)}
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleProject(id)}
/>
</Badge>
))}
{productFilter.map((id) => (
<Badge key={id} variant="secondary" className="gap-1">
{productLabel(id)}
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleProduct(id)}
/>
</Badge>
))}
{(statusFilter.length > 0 || teamFilter.length > 0 || taskTypeFilter.length > 0 || projectFilter.length > 0 || productFilter.length > 0) && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -290,6 +439,8 @@ export function TaskFilters({
clearStatuses(); clearStatuses();
clearTeams(); clearTeams();
clearTaskTypes(); clearTaskTypes();
clearProjects();
clearProducts();
}} }}
> >
Clear all Clear all
+20 -1
View File
@@ -65,6 +65,9 @@ interface SortConfig {
interface TaskTableProps { interface TaskTableProps {
tasks: Task[] | undefined; tasks: Task[] | undefined;
isLoading: boolean; isLoading: boolean;
// id -> display name maps for the Project / Product column
projectNames?: Record<string, string>;
productNames?: Record<string, string>;
// Controlled sort props (optional for backwards compatibility) // Controlled sort props (optional for backwards compatibility)
sortField?: SortField; sortField?: SortField;
sortDirection?: SortDirection; sortDirection?: SortDirection;
@@ -146,6 +149,7 @@ function TaskTableSkeleton() {
<TableCell className="whitespace-nowrap"><Skeleton className="h-6 w-16" /></TableCell> <TableCell className="whitespace-nowrap"><Skeleton className="h-6 w-16" /></TableCell>
<TableCell className="whitespace-nowrap"><Skeleton className="h-6 w-12" /></TableCell> <TableCell className="whitespace-nowrap"><Skeleton className="h-6 w-12" /></TableCell>
<TableCell className="whitespace-nowrap"><Skeleton className="h-4 w-14" /></TableCell> <TableCell className="whitespace-nowrap"><Skeleton className="h-4 w-14" /></TableCell>
<TableCell className="whitespace-nowrap"><Skeleton className="h-4 w-24" /></TableCell>
<TableCell className="whitespace-nowrap"><Skeleton className="h-4 w-8" /></TableCell> <TableCell className="whitespace-nowrap"><Skeleton className="h-4 w-8" /></TableCell>
<TableCell className="whitespace-nowrap"><Skeleton className="h-4 w-20" /></TableCell> <TableCell className="whitespace-nowrap"><Skeleton className="h-4 w-20" /></TableCell>
<TableCell className="whitespace-nowrap"><Skeleton className="h-4 w-16" /></TableCell> <TableCell className="whitespace-nowrap"><Skeleton className="h-4 w-16" /></TableCell>
@@ -159,7 +163,7 @@ function TaskTableSkeleton() {
function TaskTableEmpty() { function TaskTableEmpty() {
return ( return (
<TableRow> <TableRow>
<TableCell colSpan={8} className="text-center py-8"> <TableCell colSpan={9} className="text-center py-8">
<div className="text-muted-foreground">No tasks found</div> <div className="text-muted-foreground">No tasks found</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -197,6 +201,8 @@ function SortableHeader({ label, field, sortConfig, onSort, className }: Sortabl
export function TaskTable({ export function TaskTable({
tasks, tasks,
isLoading, isLoading,
projectNames = {},
productNames = {},
sortField: controlledSortField, sortField: controlledSortField,
sortDirection: controlledSortDirection, sortDirection: controlledSortDirection,
onSortChange, onSortChange,
@@ -412,6 +418,7 @@ export function TaskTable({
onSort={handleSort} onSort={handleSort}
className="whitespace-nowrap" className="whitespace-nowrap"
/> />
<TableHead className="whitespace-nowrap">Project / Product</TableHead>
<SortableHeader <SortableHeader
label="Priority" label="Priority"
field="priority" field="priority"
@@ -516,6 +523,18 @@ export function TaskTable({
<TableCell className="capitalize whitespace-nowrap"> <TableCell className="capitalize whitespace-nowrap">
{task.team.replace(/_/g, " ")} {task.team.replace(/_/g, " ")}
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap text-sm">
{task.project_id && projectNames[task.project_id] ? (
<span>{projectNames[task.project_id]}</span>
) : task.product_id && productNames[task.product_id] ? (
<span className="text-muted-foreground">
{productNames[task.product_id]}{" "}
<span className="text-xs">(product)</span>
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="whitespace-nowrap"> <TableCell className="whitespace-nowrap">
<Badge className={(priorityColors[task.priority] ?? priorityColors[2]) + " text-xs"}> <Badge className={(priorityColors[task.priority] ?? priorityColors[2]) + " text-xs"}>
{priorityLabels[task.priority] ?? "P2 - Medium"} {priorityLabels[task.priority] ?? "P2 - Medium"}