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

Select a project to view its Kanban board

346→
347→ ) 348→ } 349→ 350→ return ( 351→
352→ {/* Header */} 353→
354→

Kanban Board

355→
356→ {/* Priority Filter */} 357→
358→ 359→ 370→
371→ 372→ {/* Sort Order */} 373→
374→ 375→ 384→
385→ 386→ {/* Reset Filters Button */} 387→ {hasActiveFilters && ( 388→ 396→ )} 397→ 398→ {/* View Mode Toggle */} 399→
400→ 407→ 414→
415→ 416→ 417→ 418→ 422→ 423→ 424→ 425→ 426→
427→ 428→ Add New Task 429→ 430→ 431→ 434→ 435→
436→ 437→ Create a new task for this project. 438→ 439→
440→
441→ 444→ setNewTaskName(e.target.value)} 448→ className="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent" 449→ placeholder="Enter task name" 450→ /> 451→
452→
453→ 456→