1→import { useEffect, useState, useMemo } from 'react' 2→import { useSearchParams } from 'react-router-dom' 3→import { Plus, X, GripVertical, Clock, User, RefreshCw, Flag, Pencil, Check, Trash2, Filter, ArrowUpDown, LayoutGrid, List, ChevronLeft, ChevronRight, Link2, Layers } from 'lucide-react' 4→import * as Dialog from '@radix-ui/react-dialog' 5→import * as AlertDialog from '@radix-ui/react-alert-dialog' 6→import * as Tooltip from '@radix-ui/react-tooltip' 7→import { useProjectStore } from '../stores/projectStore' 8→import { useTaskStore, Task, TaskCreate, TaskUpdate } from '../stores/taskStore' 9→import { usePhaseStore } from '../stores/phaseStore' 10→import { useToastStore } from '../components/Toast' 11→ 12→const ITEMS_PER_PAGE = 10 13→ 14→const COLUMNS = [ 15→ { id: 'backlog', title: 'Backlog', color: 'border-slate-500' }, 16→ { id: 'in_progress', title: 'In Progress', color: 'border-accent' }, 17→ { id: 'qa', title: 'QA', color: 'border-primary-400' }, 18→ { id: 'done', title: 'Done', color: 'border-green-400' }, 19→ { id: 'blocked', title: 'Blocked', color: 'border-destructive' }, 20→] 21→ 22→const PRIORITY_LABELS: Record = { 23→ 0: 'Low', 24→ 1: 'Medium', 25→ 2: 'High', 26→ 3: 'Critical', 27→} 28→ 29→const STATUS_LABELS: Record = { 30→ backlog: 'Backlog', 31→ in_progress: 'In Progress', 32→ qa: 'QA', 33→ done: 'Done', 34→ blocked: 'Blocked', 35→} 36→ 37→export default function KanbanBoard() { 38→ const [searchParams, setSearchParams] = useSearchParams() 39→ const { currentProject } = useProjectStore() 40→ const { tasks, fetchTasks, createTask, updateTask, updateTaskStatus, deleteTask, isTaskDeleting, isLoading } = useTaskStore() 41→ const { phases, fetchPhases } = usePhaseStore() 42→ const { addToast } = useToastStore() 43→ const [showAddDialog, setShowAddDialog] = useState(false) 44→ const [newTaskName, setNewTaskName] = useState('') 45→ const [newTaskDescription, setNewTaskDescription] = useState('') 46→ const [newTaskPriority, setNewTaskPriority] = useState(0) 47→ const [newTaskPhaseId, setNewTaskPhaseId] = useState(null) 48→ const [isCreating, setIsCreating] = useState(false) 49→ const [draggedTask, setDraggedTask] = useState(null) 50→ const [selectedTask, setSelectedTask] = useState(null) 51→ const [isEditing, setIsEditing] = useState(false) 52→ const [editName, setEditName] = useState('') 53→ const [editDescription, setEditDescription] = useState('') 54→ const [editPriority, setEditPriority] = useState(0) 55→ const [editPhaseId, setEditPhaseId] = useState(null) 56→ const [isSaving, setIsSaving] = useState(false) 57→ const [showDeleteDialog, setShowDeleteDialog] = useState(false) 58→ const [isDeleting, setIsDeleting] = useState(false) 59→ const [showUnsavedDialog, setShowUnsavedDialog] = useState(false) 60→ const [pendingCloseAction, setPendingCloseAction] = useState<'close' | 'cancel' | null>(null) 61→ // Filter and Sort state - initialize from URL params 62→ const getInitialPriorityFilter = (): number | 'all' => { 63→ const param = searchParams.get('priority') 64→ if (param === 'all' || param === null) return 'all' 65→ const num = parseInt(param, 10) 66→ return [0, 1, 2, 3].includes(num) ? num : 'all' 67→ } 68→ const getInitialStatusFilter = (): string | 'all' => { 69→ const param = searchParams.get('status') 70→ if (param === 'all' || param === null) return 'all' 71→ const validStatuses = ['backlog', 'in_progress', 'qa', 'done', 'blocked'] 72→ return validStatuses.includes(param) ? param : 'all' 73→ } 74→ const getInitialSortOrder = (): 'newest' | 'oldest' | 'priority' => { 75→ const param = searchParams.get('sort') 76→ if (param === 'newest' || param === 'oldest' || param === 'priority') return param 77→ return 'newest' 78→ } 79→ const getInitialAgentTypeFilter = (): string | 'all' => { 80→ const param = searchParams.get('agent') 81→ if (param === 'all' || param === null) return 'all' 82→ return param 83→ } 84→ const getInitialViewMode = (): 'kanban' | 'list' => { 85→ const param = searchParams.get('view') 86→ if (param === 'list') return 'list' 87→ return 'kanban' 88→ } 89→ 90→ const [priorityFilter, setPriorityFilter] = useState(getInitialPriorityFilter) 91→ const [statusFilter, setStatusFilter] = useState(getInitialStatusFilter) 92→ const [sortOrder, setSortOrder] = useState<'newest' | 'oldest' | 'priority'>(getInitialSortOrder) 93→ const [agentTypeFilter, setAgentTypeFilter] = useState(getInitialAgentTypeFilter) 94→ // View mode and pagination state 95→ const [viewMode, setViewMode] = useState<'kanban' | 'list'>(getInitialViewMode) 96→ // Phase grouping state 97→ const [phaseGrouping, setPhaseGrouping] = useState(() => searchParams.get('groupByPhase') === 'true') 98→ const [currentPage, setCurrentPage] = useState(1) 99→ 100→ useEffect(() => { 101→ if (currentProject?.id) { 102→ fetchTasks(currentProject.id) 103→ fetchPhases(currentProject.id) 104→ } 105→ }, [currentProject?.id, fetchTasks, fetchPhases]) 106→ 107→ // Handle deep linking - select task from URL param after tasks are loaded 108→ useEffect(() => { 109→ const taskIdParam = searchParams.get('task') 110→ if (taskIdParam && tasks.length > 0) { 111→ const taskId = parseInt(taskIdParam, 10) 112→ const task = tasks.find(t => t.id === taskId) 113→ if (task) { 114→ setSelectedTask(task) 115→ } 116→ } 117→ }, [searchParams, tasks]) 118→ 119→ // Update URL when task is selected 120→ const handleSelectTask = (task: Task) => { 121→ setSelectedTask(task) 122→ const newParams = new URLSearchParams(searchParams) 123→ newParams.set('task', task.id.toString()) 124→ setSearchParams(newParams) 125→ } 126→ 127→ // Clear task from URL when panel is closed 128→ const clearTaskFromUrl = () => { 129→ const newParams = new URLSearchParams(searchParams) 130→ newParams.delete('task') 131→ setSearchParams(newParams) 132→ } 133→ 134→ // Update URL when filter changes 135→ const handlePriorityFilterChange = (value: number | 'all') => { 136→ setPriorityFilter(value) 137→ const newParams = new URLSearchParams(searchParams) 138→ if (value === 'all') { 139→ newParams.delete('priority') 140→ } else { 141→ newParams.set('priority', value.toString()) 142→ } 143→ setSearchParams(newParams) 144→ } 145→ 146→ // Update URL when status filter changes 147→ const handleStatusFilterChange = (value: string | 'all') => { 148→ setStatusFilter(value) 149→ const newParams = new URLSearchParams(searchParams) 150→ if (value === 'all') { 151→ newParams.delete('status') 152→ } else { 153→ newParams.set('status', value) 154→ } 155→ setSearchParams(newParams) 156→ } 157→ 158→ // Update URL when sort changes 159→ const handleSortOrderChange = (value: 'newest' | 'oldest' | 'priority') => { 160→ setSortOrder(value) 161→ const newParams = new URLSearchParams(searchParams) 162→ if (value === 'newest') { 163→ newParams.delete('sort') // default value, no need to store 164→ } else { 165→ newParams.set('sort', value) 166→ } 167→ setSearchParams(newParams) 168→ } 169→ 170→ // Update URL when agent type filter changes 171→ const handleAgentTypeFilterChange = (value: string | 'all') => { 172→ setAgentTypeFilter(value) 173→ const newParams = new URLSearchParams(searchParams) 174→ if (value === 'all') { 175→ newParams.delete('agent') 176→ } else { 177→ newParams.set('agent', value) 178→ } 179→ setSearchParams(newParams) 180→ } 181→ 182→ // Update URL when view mode changes 183→ const handleViewModeChange = (value: 'kanban' | 'list') => { 184→ setViewMode(value) 185→ const newParams = new URLSearchParams(searchParams) 186→ if (value === 'kanban') { 187→ newParams.delete('view') // default value, no need to store 188→ } else { 189→ newParams.set('view', value) 190→ } 191→ setSearchParams(newParams) 192→ } 193→ 194→ // Reset all filters to defaults 195→ const handleResetFilters = () => { 196→ setPriorityFilter('all') 197→ setStatusFilter('all') 198→ setAgentTypeFilter('all') 199→ setSortOrder('newest') 200→ setCurrentPage(1) 201→ const newParams = new URLSearchParams(searchParams) 202→ newParams.delete('priority') 203→ newParams.delete('status') 204→ newParams.delete('agent') 205→ newParams.delete('sort') 206→ setSearchParams(newParams) 207→ } 208→ 209→ // Check if filters are active (non-default) 210→ const hasActiveFilters = priorityFilter !== 'all' || statusFilter !== 'all' || agentTypeFilter !== 'all' || sortOrder !== 'newest' 211→ 212→ // Filtered and sorted tasks 213→ const filteredAndSortedTasks = useMemo(() => { 214→ let result = [...tasks] 215→ 216→ // Apply priority filter 217→ if (priorityFilter !== 'all') { 218→ result = result.filter(task => task.priority === priorityFilter) 219→ } 220→ 221→ // Apply status filter 222→ if (statusFilter !== 'all') { 223→ result = result.filter(task => task.status === statusFilter) 224→ } 225→ 226→ // Apply agent type filter 227→ if (agentTypeFilter !== 'all') { 228→ result = result.filter(task => task.agent_type === agentTypeFilter) 229→ } 230→ 231→ // Apply sort 232→ result.sort((a, b) => { 233→ if (sortOrder === 'newest') { 234→ return new Date(b.created_at).getTime() - new Date(a.created_at).getTime() 235→ } else if (sortOrder === 'oldest') { 236→ return new Date(a.created_at).getTime() - new Date(b.created_at).getTime() 237→ } else { 238→ // priority - highest first 239→ return b.priority - a.priority 240→ } 241→ }) 242→ 243→ return result 244→ }, [tasks, priorityFilter, statusFilter, agentTypeFilter, sortOrder]) 245→ 246→ // Pagination calculations 247→ const totalPages = Math.ceil(filteredAndSortedTasks.length / ITEMS_PER_PAGE) 248→ const paginatedTasks = useMemo(() => { 249→ const startIndex = (currentPage - 1) * ITEMS_PER_PAGE 250→ return filteredAndSortedTasks.slice(startIndex, startIndex + ITEMS_PER_PAGE) 251→ }, [filteredAndSortedTasks, currentPage]) 252→ 253→ // Reset page when filter changes 254→ useEffect(() => { 255→ setCurrentPage(1) 256→ }, [priorityFilter, statusFilter, agentTypeFilter, sortOrder]) 257→ 258→ const getTasksByStatus = (status: string) => { 259→ return filteredAndSortedTasks.filter((task) => task.status === status) 260→ } 261→ 262→ // Helper to get dependency task names 263→ const getDependencyNames = (dependsOn: number[] | null): string[] => { 264→ if (!dependsOn || dependsOn.length === 0) return [] 265→ return dependsOn 266→ .map(taskId => tasks.find(t => t.id === taskId)?.name || `Task #${taskId}`) 267→ } 268→ 269→ const handleCreateTask = async () => { 270→ if (!currentProject || !newTaskName.trim()) return 271→ setIsCreating(true) 272→ try { 273→ const taskData: TaskCreate = { 274→ name: newTaskName.trim(), 275→ description: newTaskDescription.trim() || undefined, 276→ priority: newTaskPriority, 277→ phase_id: newTaskPhaseId, 278→ } 279→ await createTask(currentProject.id, taskData) 280→ setNewTaskName('') 281→ setNewTaskDescription('') 282→ setNewTaskPriority(0) 283→ setNewTaskPhaseId(null) 284→ setShowAddDialog(false) 285→ } catch (error) { 286→ console.error('Failed to create task:', error) 287→ addToast('error', 'Create Failed', 'Failed to create task. Please try again.') 288→ } finally { 289→ setIsCreating(false) 290→ } 291→ } 292→ 293→ const handleDragStart = (taskId: number) => { 294→ setDraggedTask(taskId) 295→ } 296→ 297→ const handleDragOver = (e: React.DragEvent) => { 298→ e.preventDefault() 299→ } 300→ 301→ const handleDrop = async (targetStatus: string) => { 302→ if (draggedTask === null) return 303→ try { 304→ await updateTaskStatus(draggedTask, targetStatus) 305→ } catch (error) { 306→ console.error('Failed to update task status:', error) 307→ addToast('error', 'Update Failed', 'Failed to update task status. Please try again.') 308→ } 309→ setDraggedTask(null) 310→ } 311→ 312→ const handleStartEdit = () => { 313→ if (!selectedTask) return 314→ setEditName(selectedTask.name) 315→ setEditDescription(selectedTask.description || '') 316→ setEditPriority(selectedTask.priority) 317→ setEditPhaseId(selectedTask.phase_id) 318→ setIsEditing(true) 319→ } 320→ 321→ // Check if edit form has unsaved changes 322→ const hasUnsavedChanges = () => { 323→ if (!selectedTask || !isEditing) return false 324→ return ( 325→ editName.trim() !== selectedTask.name || 326→ (editDescription.trim() || '') !== (selectedTask.description || '') || 327→ editPriority !== selectedTask.priority || 328→ editPhaseId !== selectedTask.phase_id 329→ ) 330→ } 331→ 332→ const handleCancelEdit = () => { 333→ if (hasUnsavedChanges()) { 334→ setPendingCloseAction('cancel') 335→ setShowUnsavedDialog(true) 336→ } else { 337→ setIsEditing(false) 338→ } 339→ } 340→ 341→ const handleClosePanel = () => { 342→ if (hasUnsavedChanges()) { 343→ setPendingCloseAction('close') 344→ setShowUnsavedDialog(true) 345→ } else { 346→ setSelectedTask(null) 347→ setIsEditing(false) 348→ clearTaskFromUrl() 349→ } 350→ } 351→ 352→ const handleDiscardChanges = () => { 353→ setShowUnsavedDialog(false) 354→ if (pendingCloseAction === 'close') { 355→ setSelectedTask(null) 356→ clearTaskFromUrl() 357→ } 358→ setIsEditing(false) 359→ setPendingCloseAction(null) 360→ } 361→ 362→ const handleKeepEditing = () => { 363→ setShowUnsavedDialog(false) 364→ setPendingCloseAction(null) 365→ } 366→ 367→ const handleSaveEdit = async () => { 368→ if (!selectedTask) return 369→ setIsSaving(true) 370→ try { 371→ const updates: TaskUpdate = { 372→ name: editName.trim(), 373→ description: editDescription.trim() || undefined, 374→ priority: editPriority, 375→ phase_id: editPhaseId, 376→ } 377→ const updatedTask = await updateTask(selectedTask.id, updates) 378→ setSelectedTask(updatedTask) 379→ setIsEditing(false) 380→ } catch (error) { 381→ console.error('Failed to update task:', error) 382→ addToast('error', 'Save Failed', 'Failed to save task changes. Please try again.') 383→ } finally { 384→ setIsSaving(false) 385→ } 386→ } 387→ 388→ const handleDeleteTask = async () => { 389→ if (!selectedTask) return 390→ // Check if already being deleted (prevents rapid clicks) 391→ if (isTaskDeleting(selectedTask.id)) return 392→ 393→ const taskIdToDelete = selectedTask.id 394→ const taskName = selectedTask.name 395→ setIsDeleting(true) 396→ // Close dialog immediately to prevent additional clicks 397→ setShowDeleteDialog(false) 398→ 399→ try { 400→ await deleteTask(taskIdToDelete) 401→ setSelectedTask(null) 402→ clearTaskFromUrl() 403→ } catch (error) { 404→ console.error('Failed to delete task:', error) 405→ addToast('error', 'Delete Failed', `Failed to delete task "${taskName}". Please try again.`) 406→ // Re-open dialog if delete failed so user can retry 407→ setShowDeleteDialog(true) 408→ } finally { 409→ setIsDeleting(false) 410→ } 411→ } 412→ 413→ if (!currentProject) { 414→ return ( 415→
416→

Select a project to view its Kanban board

417→
418→ ) 419→ } 420→ 421→ return ( 422→
423→ {/* Header */} 424→
425→

Kanban Board

426→
427→ {/* Priority Filter */} 428→
429→ 430→ 441→
442→ 443→ {/* Status Filter */} 444→
445→ 457→
458→ 459→ {/* Agent Type Filter */} 460→
461→ 462→ 473→
474→ 475→ {/* Sort Order */} 476→
477→ 478→ 487→
488→ 489→ {/* Reset Filters Button */} 490→ {hasActiveFilters && ( 491→ 499→ )} 500→ 501→ {/* View Mode Toggle */} 502→
503→ 510→ 517→
518→ 519→ 520→ 521→ 525→ 526→ 527→ 528→ 529→
530→ 531→ Add New Task 532→ 533→ 534→ 537→ 538→
539→ 540→ Create a new task for this project. 541→ 542→
543→
544→ 547→ setNewTaskName(e.target.value)} 551→ className="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent" 552→ placeholder="Enter task name" 553→ /> 554→
555→
556→ 559→