diff --git a/TaskTracker.Web/src/components/FilterBar.tsx b/TaskTracker.Web/src/components/FilterBar.tsx new file mode 100644 index 0000000..c1d6127 --- /dev/null +++ b/TaskTracker.Web/src/components/FilterBar.tsx @@ -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 ( +
+ {/* "All" chip */} + + + {/* Divider */} +
+ + {/* Category chips */} + {allCategories.map((cat) => { + const isActive = filters.categories.includes(cat) + const color = CATEGORY_COLORS[cat] ?? CATEGORY_COLORS['Unknown'] + + return ( + + ) + })} + + {/* Divider */} +
+ + {/* Has subtasks chip */} + +
+ ) +} diff --git a/TaskTracker.Web/src/components/KanbanBoard.tsx b/TaskTracker.Web/src/components/KanbanBoard.tsx index e03aebb..c261b16 100644 --- a/TaskTracker.Web/src/components/KanbanBoard.tsx +++ b/TaskTracker.Web/src/components/KanbanBoard.tsx @@ -11,7 +11,6 @@ import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core' import { useState } from 'react' import { Loader2 } from 'lucide-react' import { - useTasks, useStartTask, usePauseTask, useResumeTask, @@ -24,11 +23,12 @@ import KanbanColumn from './KanbanColumn.tsx' import TaskCard from './TaskCard.tsx' interface KanbanBoardProps { + tasks: WorkTask[] + isLoading: boolean onTaskClick: (id: number) => void } -export default function KanbanBoard({ onTaskClick }: KanbanBoardProps) { - const { data: tasks, isLoading } = useTasks() +export default function KanbanBoard({ tasks, isLoading, onTaskClick }: KanbanBoardProps) { const startTask = useStartTask() const pauseTask = usePauseTask() const resumeTask = useResumeTask() @@ -42,7 +42,7 @@ export default function KanbanBoard({ onTaskClick }: KanbanBoardProps) { // Filter to top-level tasks only and group by status const columns = useMemo(() => { - const topLevel = (tasks ?? []).filter((t) => t.parentTaskId === null) + const topLevel = tasks.filter((t) => t.parentTaskId === null) return COLUMN_CONFIG.map((col) => ({ ...col, tasks: topLevel.filter((t) => t.status === col.status), @@ -52,7 +52,7 @@ export default function KanbanBoard({ onTaskClick }: KanbanBoardProps) { const handleDragStart = useCallback( (event: DragStartEvent) => { 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) }, [tasks] @@ -66,7 +66,7 @@ export default function KanbanBoard({ onTaskClick }: KanbanBoardProps) { if (!over) return const taskId = Number(active.id) - const task = (tasks ?? []).find((t) => t.id === taskId) + const task = tasks.find((t) => t.id === taskId) if (!task) return // Determine target status from the droppable column ID @@ -78,7 +78,7 @@ export default function KanbanBoard({ onTaskClick }: KanbanBoardProps) { } else { // Dropped over another card - find which column it belongs to const overTaskId = Number(over.id) - const overTask = (tasks ?? []).find((t) => t.id === overTaskId) + const overTask = tasks.find((t) => t.id === overTaskId) if (overTask) { targetStatus = overTask.status } diff --git a/TaskTracker.Web/src/components/Layout.tsx b/TaskTracker.Web/src/components/Layout.tsx index f846fa3..f64fbd0 100644 --- a/TaskTracker.Web/src/components/Layout.tsx +++ b/TaskTracker.Web/src/components/Layout.tsx @@ -1,6 +1,7 @@ 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 SearchBar from './SearchBar.tsx' const navItems = [ { to: '/board', label: 'Board', icon: LayoutGrid }, @@ -10,6 +11,7 @@ const navItems = [ export default function Layout() { const [collapsed, setCollapsed] = useState(false) + const navigate = useNavigate() return (
@@ -54,7 +56,7 @@ export default function Layout() { {/* Top bar */}

TaskTracker

-
+ navigate(`/board?task=${taskId}`)} />
{/* Content */} diff --git a/TaskTracker.Web/src/components/SearchBar.tsx b/TaskTracker.Web/src/components/SearchBar.tsx new file mode 100644 index 0000000..461e3e5 --- /dev/null +++ b/TaskTracker.Web/src/components/SearchBar.tsx @@ -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(null) + const inputRef = useRef(null) + const timerRef = useRef | 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 ( +
+
+ + 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" + /> +
+ + {isOpen && results.length > 0 && ( +
+ {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 ( + + ) + })} +
+ )} +
+ ) +} diff --git a/TaskTracker.Web/src/pages/Board.tsx b/TaskTracker.Web/src/pages/Board.tsx index 6bea738..fc5d07e 100644 --- a/TaskTracker.Web/src/pages/Board.tsx +++ b/TaskTracker.Web/src/pages/Board.tsx @@ -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 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() { const [selectedTaskId, setSelectedTaskId] = useState(null) + const [searchParams, setSearchParams] = useSearchParams() + const [filters, setFilters] = useState(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 ( -
- setSelectedTaskId(id)} /> +
+ +
+ setSelectedTaskId(id)} + /> +
{selectedTaskId !== null && ( setSelectedTaskId(null)} /> )}