mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -182,6 +238,8 @@ function TasksPageContent() {
|
||||
<TaskTable
|
||||
tasks={filteredTasks}
|
||||
isLoading={isLoading}
|
||||
projectNames={projectNames}
|
||||
productNames={productNames}
|
||||
sortField={sortField}
|
||||
sortDirection={sortDir}
|
||||
onSortChange={handleSortChange}
|
||||
|
||||
@@ -23,6 +23,13 @@ interface TaskFiltersProps {
|
||||
// Optional new filters
|
||||
taskTypeFilter?: TaskType[];
|
||||
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> = {
|
||||
@@ -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 (
|
||||
<Card>
|
||||
@@ -248,11 +285,105 @@ export function TaskFilters({
|
||||
</PopoverContent>
|
||||
</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>
|
||||
|
||||
{/* 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">
|
||||
{statusFilter.map((status) => (
|
||||
<Badge key={status} variant="secondary" className="gap-1">
|
||||
@@ -281,7 +412,25 @@ export function TaskFilters({
|
||||
/>
|
||||
</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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -290,6 +439,8 @@ export function TaskFilters({
|
||||
clearStatuses();
|
||||
clearTeams();
|
||||
clearTaskTypes();
|
||||
clearProjects();
|
||||
clearProducts();
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
|
||||
@@ -65,6 +65,9 @@ interface SortConfig {
|
||||
interface TaskTableProps {
|
||||
tasks: Task[] | undefined;
|
||||
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)
|
||||
sortField?: SortField;
|
||||
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-12" /></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-20" /></TableCell>
|
||||
<TableCell className="whitespace-nowrap"><Skeleton className="h-4 w-16" /></TableCell>
|
||||
@@ -159,7 +163,7 @@ function TaskTableSkeleton() {
|
||||
function TaskTableEmpty() {
|
||||
return (
|
||||
<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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -197,6 +201,8 @@ function SortableHeader({ label, field, sortConfig, onSort, className }: Sortabl
|
||||
export function TaskTable({
|
||||
tasks,
|
||||
isLoading,
|
||||
projectNames = {},
|
||||
productNames = {},
|
||||
sortField: controlledSortField,
|
||||
sortDirection: controlledSortDirection,
|
||||
onSortChange,
|
||||
@@ -412,6 +418,7 @@ export function TaskTable({
|
||||
onSort={handleSort}
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
<TableHead className="whitespace-nowrap">Project / Product</TableHead>
|
||||
<SortableHeader
|
||||
label="Priority"
|
||||
field="priority"
|
||||
@@ -516,6 +523,18 @@ export function TaskTable({
|
||||
<TableCell className="capitalize whitespace-nowrap">
|
||||
{task.team.replace(/_/g, " ")}
|
||||
</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">
|
||||
<Badge className={(priorityColors[task.priority] ?? priorityColors[2]) + " text-xs"}>
|
||||
{priorityLabels[task.priority] ?? "P2 - Medium"}
|
||||
|
||||
Reference in New Issue
Block a user