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 { 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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
Reference in New Issue
Block a user