feat: add global search bar and board filter chips
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
137
TaskTracker.Web/src/components/FilterBar.tsx
Normal file
137
TaskTracker.Web/src/components/FilterBar.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { X, ListTree } from 'lucide-react'
|
||||||
|
import type { WorkTask } from '../types/index.ts'
|
||||||
|
import { CATEGORY_COLORS } from '../lib/constants.ts'
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
categories: string[]
|
||||||
|
hasSubtasks: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_FILTERS: Filters = { categories: [], hasSubtasks: false }
|
||||||
|
|
||||||
|
interface FilterBarProps {
|
||||||
|
tasks: WorkTask[]
|
||||||
|
filters: Filters
|
||||||
|
onFiltersChange: (filters: Filters) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFilters(tasks: WorkTask[], filters: Filters): WorkTask[] {
|
||||||
|
let result = tasks
|
||||||
|
|
||||||
|
if (filters.categories.length > 0) {
|
||||||
|
result = result.filter((t) => filters.categories.includes(t.category ?? 'Unknown'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.hasSubtasks) {
|
||||||
|
result = result.filter((t) => t.subTasks && t.subTasks.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilterBar({ tasks, filters, onFiltersChange }: FilterBarProps) {
|
||||||
|
// Derive unique categories from tasks + CATEGORY_COLORS keys
|
||||||
|
const allCategories = useMemo(() => {
|
||||||
|
const fromTasks = new Set(tasks.map((t) => t.category ?? 'Unknown'))
|
||||||
|
const fromConfig = new Set(Object.keys(CATEGORY_COLORS))
|
||||||
|
const merged = new Set([...fromConfig, ...fromTasks])
|
||||||
|
return Array.from(merged).sort()
|
||||||
|
}, [tasks])
|
||||||
|
|
||||||
|
const hasActiveFilters = filters.categories.length > 0 || filters.hasSubtasks
|
||||||
|
|
||||||
|
const toggleCategory = (category: string) => {
|
||||||
|
const active = filters.categories
|
||||||
|
const next = active.includes(category)
|
||||||
|
? active.filter((c) => c !== category)
|
||||||
|
: [...active, category]
|
||||||
|
onFiltersChange({ ...filters, categories: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleHasSubtasks = () => {
|
||||||
|
onFiltersChange({ ...filters, hasSubtasks: !filters.hasSubtasks })
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
onFiltersChange(EMPTY_FILTERS)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-4">
|
||||||
|
{/* "All" chip */}
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors ${
|
||||||
|
!hasActiveFilters
|
||||||
|
? 'bg-indigo-500 text-white'
|
||||||
|
: 'bg-[#2a2d37] text-[#94a3b8] hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="w-px h-4 bg-white/10" />
|
||||||
|
|
||||||
|
{/* Category chips */}
|
||||||
|
{allCategories.map((cat) => {
|
||||||
|
const isActive = filters.categories.includes(cat)
|
||||||
|
const color = CATEGORY_COLORS[cat] ?? CATEGORY_COLORS['Unknown']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => toggleCategory(cat)}
|
||||||
|
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors ${
|
||||||
|
isActive ? 'text-white' : 'bg-[#2a2d37] text-[#94a3b8] hover:text-white'
|
||||||
|
}`}
|
||||||
|
style={
|
||||||
|
isActive
|
||||||
|
? { backgroundColor: color, color: '#fff' }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
{isActive && (
|
||||||
|
<X
|
||||||
|
size={10}
|
||||||
|
className="ml-0.5 opacity-70 hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
toggleCategory(cat)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="w-px h-4 bg-white/10" />
|
||||||
|
|
||||||
|
{/* Has subtasks chip */}
|
||||||
|
<button
|
||||||
|
onClick={toggleHasSubtasks}
|
||||||
|
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors ${
|
||||||
|
filters.hasSubtasks
|
||||||
|
? 'bg-indigo-500 text-white'
|
||||||
|
: 'bg-[#2a2d37] text-[#94a3b8] hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListTree size={12} />
|
||||||
|
Has subtasks
|
||||||
|
{filters.hasSubtasks && (
|
||||||
|
<X
|
||||||
|
size={10}
|
||||||
|
className="ml-0.5 opacity-70 hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
toggleHasSubtasks()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
useTasks,
|
|
||||||
useStartTask,
|
useStartTask,
|
||||||
usePauseTask,
|
usePauseTask,
|
||||||
useResumeTask,
|
useResumeTask,
|
||||||
@@ -24,11 +23,12 @@ import KanbanColumn from './KanbanColumn.tsx'
|
|||||||
import TaskCard from './TaskCard.tsx'
|
import TaskCard from './TaskCard.tsx'
|
||||||
|
|
||||||
interface KanbanBoardProps {
|
interface KanbanBoardProps {
|
||||||
|
tasks: WorkTask[]
|
||||||
|
isLoading: boolean
|
||||||
onTaskClick: (id: number) => void
|
onTaskClick: (id: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function KanbanBoard({ onTaskClick }: KanbanBoardProps) {
|
export default function KanbanBoard({ tasks, isLoading, onTaskClick }: KanbanBoardProps) {
|
||||||
const { data: tasks, isLoading } = useTasks()
|
|
||||||
const startTask = useStartTask()
|
const startTask = useStartTask()
|
||||||
const pauseTask = usePauseTask()
|
const pauseTask = usePauseTask()
|
||||||
const resumeTask = useResumeTask()
|
const resumeTask = useResumeTask()
|
||||||
@@ -42,7 +42,7 @@ export default function KanbanBoard({ onTaskClick }: KanbanBoardProps) {
|
|||||||
|
|
||||||
// Filter to top-level tasks only and group by status
|
// Filter to top-level tasks only and group by status
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
const topLevel = (tasks ?? []).filter((t) => t.parentTaskId === null)
|
const topLevel = tasks.filter((t) => t.parentTaskId === null)
|
||||||
return COLUMN_CONFIG.map((col) => ({
|
return COLUMN_CONFIG.map((col) => ({
|
||||||
...col,
|
...col,
|
||||||
tasks: topLevel.filter((t) => t.status === col.status),
|
tasks: topLevel.filter((t) => t.status === col.status),
|
||||||
@@ -52,7 +52,7 @@ export default function KanbanBoard({ onTaskClick }: KanbanBoardProps) {
|
|||||||
const handleDragStart = useCallback(
|
const handleDragStart = useCallback(
|
||||||
(event: DragStartEvent) => {
|
(event: DragStartEvent) => {
|
||||||
const draggedId = Number(event.active.id)
|
const draggedId = Number(event.active.id)
|
||||||
const task = (tasks ?? []).find((t) => t.id === draggedId) ?? null
|
const task = tasks.find((t) => t.id === draggedId) ?? null
|
||||||
setActiveTask(task)
|
setActiveTask(task)
|
||||||
},
|
},
|
||||||
[tasks]
|
[tasks]
|
||||||
@@ -66,7 +66,7 @@ export default function KanbanBoard({ onTaskClick }: KanbanBoardProps) {
|
|||||||
if (!over) return
|
if (!over) return
|
||||||
|
|
||||||
const taskId = Number(active.id)
|
const taskId = Number(active.id)
|
||||||
const task = (tasks ?? []).find((t) => t.id === taskId)
|
const task = tasks.find((t) => t.id === taskId)
|
||||||
if (!task) return
|
if (!task) return
|
||||||
|
|
||||||
// Determine target status from the droppable column ID
|
// Determine target status from the droppable column ID
|
||||||
@@ -78,7 +78,7 @@ export default function KanbanBoard({ onTaskClick }: KanbanBoardProps) {
|
|||||||
} else {
|
} else {
|
||||||
// Dropped over another card - find which column it belongs to
|
// Dropped over another card - find which column it belongs to
|
||||||
const overTaskId = Number(over.id)
|
const overTaskId = Number(over.id)
|
||||||
const overTask = (tasks ?? []).find((t) => t.id === overTaskId)
|
const overTask = tasks.find((t) => t.id === overTaskId)
|
||||||
if (overTask) {
|
if (overTask) {
|
||||||
targetStatus = overTask.status
|
targetStatus = overTask.status
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { NavLink, Outlet } from 'react-router-dom'
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||||
import { LayoutGrid, BarChart3, Link, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
|
import { LayoutGrid, BarChart3, Link, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
|
||||||
|
import SearchBar from './SearchBar.tsx'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/board', label: 'Board', icon: LayoutGrid },
|
{ to: '/board', label: 'Board', icon: LayoutGrid },
|
||||||
@@ -10,6 +11,7 @@ const navItems = [
|
|||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-[#0f1117] text-white overflow-hidden">
|
<div className="flex h-screen bg-[#0f1117] text-white overflow-hidden">
|
||||||
@@ -54,7 +56,7 @@ export default function Layout() {
|
|||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<header className="flex items-center justify-between h-14 px-6 border-b border-white/5 shrink-0">
|
<header className="flex items-center justify-between h-14 px-6 border-b border-white/5 shrink-0">
|
||||||
<h1 className="text-lg font-semibold tracking-tight">TaskTracker</h1>
|
<h1 className="text-lg font-semibold tracking-tight">TaskTracker</h1>
|
||||||
<div className="w-64 h-8 rounded-md bg-white/5" />
|
<SearchBar onSelect={(taskId) => navigate(`/board?task=${taskId}`)} />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|||||||
179
TaskTracker.Web/src/components/SearchBar.tsx
Normal file
179
TaskTracker.Web/src/components/SearchBar.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import { useTasks } from '../api/tasks.ts'
|
||||||
|
import { CATEGORY_COLORS, COLUMN_CONFIG } from '../lib/constants.ts'
|
||||||
|
import type { WorkTask } from '../types/index.ts'
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
onSelect: (taskId: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchBar({ onSelect }: SearchBarProps) {
|
||||||
|
const { data: tasks } = useTasks()
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
// Debounce the query by 200ms
|
||||||
|
useEffect(() => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setDebouncedQuery(query)
|
||||||
|
}, 200)
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
|
}
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
// Filter tasks based on debounced query
|
||||||
|
const results: WorkTask[] = (() => {
|
||||||
|
if (!debouncedQuery.trim() || !tasks) return []
|
||||||
|
const q = debouncedQuery.toLowerCase()
|
||||||
|
return tasks
|
||||||
|
.filter(
|
||||||
|
(t) =>
|
||||||
|
t.title.toLowerCase().includes(q) ||
|
||||||
|
(t.description && t.description.toLowerCase().includes(q))
|
||||||
|
)
|
||||||
|
.slice(0, 8)
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Open/close dropdown based on results
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOpen(results.length > 0)
|
||||||
|
setSelectedIndex(0)
|
||||||
|
}, [results.length])
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(taskId: number) => {
|
||||||
|
onSelect(taskId)
|
||||||
|
setQuery('')
|
||||||
|
setDebouncedQuery('')
|
||||||
|
setIsOpen(false)
|
||||||
|
inputRef.current?.blur()
|
||||||
|
},
|
||||||
|
[onSelect]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen) return
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault()
|
||||||
|
if (results[selectedIndex]) {
|
||||||
|
handleSelect(results[selectedIndex].id)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
setIsOpen(false)
|
||||||
|
inputRef.current?.blur()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isOpen, results, selectedIndex, handleSelect]
|
||||||
|
)
|
||||||
|
|
||||||
|
const getStatusLabel = (status: number) => {
|
||||||
|
const col = COLUMN_CONFIG.find((c) => c.status === status)
|
||||||
|
return col ? col.label : 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusColor = (status: number) => {
|
||||||
|
const col = COLUMN_CONFIG.find((c) => c.status === status)
|
||||||
|
return col ? col.color : '#64748b'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative w-[300px]">
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
size={16}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-[#94a3b8] pointer-events-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => {
|
||||||
|
if (results.length > 0) setIsOpen(true)
|
||||||
|
}}
|
||||||
|
placeholder="Search tasks..."
|
||||||
|
className="w-full h-8 pl-9 pr-3 rounded-full bg-[#1a1d27] text-white text-sm placeholder-[#94a3b8] border border-white/5 focus:border-indigo-500/50 focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && results.length > 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-2 rounded-lg bg-[#1a1d27] border border-white/10 shadow-xl shadow-black/40 z-50 overflow-hidden">
|
||||||
|
{results.map((task, index) => {
|
||||||
|
const categoryColor =
|
||||||
|
CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown']
|
||||||
|
const statusColor = getStatusColor(task.status)
|
||||||
|
const statusLabel = getStatusLabel(task.status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={task.id}
|
||||||
|
onClick={() => handleSelect(task.id)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${
|
||||||
|
index === selectedIndex ? 'bg-[#2a2d37]' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Status dot */}
|
||||||
|
<span
|
||||||
|
className="shrink-0 w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: statusColor }}
|
||||||
|
title={statusLabel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<span className="flex-1 text-sm text-white truncate">{task.title}</span>
|
||||||
|
|
||||||
|
{/* Category badge */}
|
||||||
|
{task.category && (
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: categoryColor + '20',
|
||||||
|
color: categoryColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,46 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { useTasks } from '../api/tasks.ts'
|
||||||
import KanbanBoard from '../components/KanbanBoard.tsx'
|
import KanbanBoard from '../components/KanbanBoard.tsx'
|
||||||
import TaskDetailPanel from '../components/TaskDetailPanel.tsx'
|
import TaskDetailPanel from '../components/TaskDetailPanel.tsx'
|
||||||
|
import FilterBar, { applyFilters, EMPTY_FILTERS } from '../components/FilterBar.tsx'
|
||||||
|
import type { Filters } from '../components/FilterBar.tsx'
|
||||||
|
|
||||||
export default function Board() {
|
export default function Board() {
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null)
|
const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null)
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS)
|
||||||
|
const { data: tasks, isLoading } = useTasks()
|
||||||
|
|
||||||
|
// Read ?task= search param on mount or when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const taskParam = searchParams.get('task')
|
||||||
|
if (taskParam) {
|
||||||
|
const id = Number(taskParam)
|
||||||
|
if (!isNaN(id)) {
|
||||||
|
setSelectedTaskId(id)
|
||||||
|
}
|
||||||
|
// Clean up the search param
|
||||||
|
setSearchParams({}, { replace: true })
|
||||||
|
}
|
||||||
|
}, [searchParams, setSearchParams])
|
||||||
|
|
||||||
|
// Apply filters to tasks
|
||||||
|
const filteredTasks = useMemo(() => {
|
||||||
|
if (!tasks) return []
|
||||||
|
return applyFilters(tasks, filters)
|
||||||
|
}, [tasks, filters])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full flex flex-col">
|
||||||
<KanbanBoard onTaskClick={(id) => setSelectedTaskId(id)} />
|
<FilterBar tasks={tasks ?? []} filters={filters} onFiltersChange={setFilters} />
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<KanbanBoard
|
||||||
|
tasks={filteredTasks}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onTaskClick={(id) => setSelectedTaskId(id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{selectedTaskId !== null && (
|
{selectedTaskId !== null && (
|
||||||
<TaskDetailPanel taskId={selectedTaskId} onClose={() => setSelectedTaskId(null)} />
|
<TaskDetailPanel taskId={selectedTaskId} onClose={() => setSelectedTaskId(null)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user