Redesign Backlog view: vertical collapsible sections with drag-and-drop

This commit is contained in:
OpenClaw Bot 2026-02-19 19:31:47 -06:00
parent af0e467cc1
commit bbde17ec11

View File

@ -18,10 +18,11 @@ import {
} from "@dnd-kit/sortable" } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities" import { CSS } from "@dnd-kit/utilities"
import { useTaskStore, Task, Sprint } from "@/stores/useTaskStore" import { useTaskStore, Task, Sprint } from "@/stores/useTaskStore"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Plus, GripVertical, Search, Filter } from "lucide-react" import { Plus, GripVertical, ChevronDown, ChevronRight, Calendar } from "lucide-react"
import { format } from "date-fns"
const priorityColors: Record<string, string> = { const priorityColors: Record<string, string> = {
low: "bg-slate-600", low: "bg-slate-600",
@ -31,32 +32,20 @@ const priorityColors: Record<string, string> = {
} }
const typeLabels: Record<string, string> = { const typeLabels: Record<string, string> = {
idea: "💡 Idea", idea: "💡",
task: "📋 Task", task: "📋",
bug: "🐛 Bug", bug: "🐛",
research: "🔬 Research", research: "🔬",
plan: "📐 Plan", plan: "📐",
} }
const typeColors: Record<string, string> = { // Sortable Task Row
idea: "bg-purple-500", function SortableTaskRow({
task: "bg-blue-500",
bug: "bg-red-500",
research: "bg-green-500",
plan: "bg-amber-500",
}
// Sortable Backlog Item
function SortableBacklogItem({
task, task,
sprints,
onClick, onClick,
onAssignToSprint,
}: { }: {
task: Task task: Task
sprints: Sprint[]
onClick: () => void onClick: () => void
onAssignToSprint: (sprintId: string) => void
}) { }) {
const { const {
attributes, attributes,
@ -73,105 +62,117 @@ function SortableBacklogItem({
opacity: isDragging ? 0.5 : 1, opacity: isDragging ? 0.5 : 1,
} }
const currentSprint = sprints.find((s) => s.id === task.sprintId)
return ( return (
<Card <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className="bg-slate-800 border-slate-700 hover:border-slate-600 transition-colors group" className="flex items-center gap-3 p-3 bg-slate-800/50 border border-slate-700/50 rounded-lg hover:border-slate-600 cursor-pointer group"
onClick={onClick}
> >
<CardContent className="p-3"> <div
<div className="flex items-center gap-3"> {...attributes}
<div {...listeners}
{...attributes} className="opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing"
{...listeners} >
className="opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing" <GripVertical className="w-4 h-4 text-slate-500" />
> </div>
<GripVertical className="w-4 h-4 text-slate-500" /> <span className="text-lg">{typeLabels[task.type]}</span>
</div> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-200 truncate">{task.title}</p>
<div className="flex-1 min-w-0" onClick={onClick}> </div>
<div className="flex items-center gap-2 mb-1"> <Badge className={`${priorityColors[task.priority]} text-white text-xs`}>
<Badge {task.priority}
className={`${typeColors[task.type]} text-white text-xs border-0`} </Badge>
> {task.comments.length > 0 && (
{typeLabels[task.type]} <span className="text-xs text-slate-500">💬 {task.comments.length}</span>
</Badge> )}
<Badge </div>
className={`${priorityColors[task.priority]} text-white text-xs`}
>
{task.priority}
</Badge>
{task.comments.length > 0 && (
<span className="text-xs text-slate-500">
💬 {task.comments.length}
</span>
)}
</div>
<h4 className="text-sm font-medium text-slate-200 truncate">
{task.title}
</h4>
</div>
<div className="flex items-center gap-2">
{currentSprint ? (
<Badge variant="outline" className="text-xs border-blue-500 text-blue-400">
{currentSprint.name}
</Badge>
) : (
<select
onChange={(e) => {
if (e.target.value) {
onAssignToSprint(e.target.value)
e.target.value = ""
}
}}
className="bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300 focus:outline-none focus:border-blue-500"
defaultValue=""
>
<option value="">+ Sprint</option>
{sprints.map((sprint) => (
<option key={sprint.id} value={sprint.id}>
{sprint.name}
</option>
))}
</select>
)}
</div>
</div>
</CardContent>
</Card>
) )
} }
// Drag Overlay Item // Drag Overlay Item
function DragOverlayItem({ task }: { task: Task }) { function DragOverlayItem({ task }: { task: Task }) {
return ( return (
<Card className="bg-slate-800 border-slate-600 shadow-xl rotate-1"> <div className="flex items-center gap-3 p-3 bg-slate-800 border border-slate-600 rounded-lg shadow-xl rotate-1">
<CardContent className="p-3"> <GripVertical className="w-4 h-4 text-slate-500" />
<span className="text-lg">{typeLabels[task.type]}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-200 truncate">{task.title}</p>
</div>
<Badge className={`${priorityColors[task.priority]} text-white text-xs`}>
{task.priority}
</Badge>
</div>
)
}
// Collapsible Section
function TaskSection({
title,
tasks,
isOpen,
onToggle,
onTaskClick,
sprintInfo,
}: {
title: string
tasks: Task[]
isOpen: boolean
onToggle: () => void
onTaskClick: (task: Task) => void
sprintInfo?: { name: string; date: string; status: string }
}) {
return (
<div className="border border-slate-800 rounded-lg overflow-hidden">
<button
onClick={onToggle}
className="w-full flex items-center justify-between p-4 bg-slate-900 hover:bg-slate-800/50 transition-colors"
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<GripVertical className="w-4 h-4 text-slate-500" /> {isOpen ? (
<div className="flex-1 min-w-0"> <ChevronDown className="w-5 h-5 text-slate-400" />
<div className="flex items-center gap-2 mb-1"> ) : (
<Badge <ChevronRight className="w-5 h-5 text-slate-400" />
className={`${typeColors[task.type]} text-white text-xs border-0`} )}
> <h3 className="font-medium text-slate-200">{title}</h3>
{typeLabels[task.type]} <Badge variant="secondary" className="bg-slate-800 text-slate-400">
</Badge> {tasks.length}
<Badge </Badge>
className={`${priorityColors[task.priority]} text-white text-xs`}
>
{task.priority}
</Badge>
</div>
<h4 className="text-sm font-medium text-slate-200 truncate">
{task.title}
</h4>
</div>
</div> </div>
</CardContent> {sprintInfo && (
</Card> <div className="flex items-center gap-2 text-sm text-slate-500">
<Calendar className="w-4 h-4" />
<span>{sprintInfo.date}</span>
<Badge variant="outline" className="text-xs">
{sprintInfo.status}
</Badge>
</div>
)}
</button>
{isOpen && (
<div className="p-4 bg-slate-950/50">
<SortableContext
items={tasks.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{tasks.length === 0 ? (
<p className="text-sm text-slate-600 text-center py-4">No tasks</p>
) : (
tasks.map((task) => (
<SortableTaskRow
key={task.id}
task={task}
onClick={() => onTaskClick(task)}
/>
))
)}
</div>
</SortableContext>
</div>
)}
</div>
) )
} }
@ -180,17 +181,24 @@ export function BacklogView() {
tasks, tasks,
sprints, sprints,
selectedProjectId, selectedProjectId,
selectedTaskId,
updateTask,
selectTask, selectTask,
addTask, updateTask,
addSprint,
} = useTaskStore() } = useTaskStore()
const [searchQuery, setSearchQuery] = useState("")
const [filterType, setFilterType] = useState<string>("all")
const [filterPriority, setFilterPriority] = useState<string>("all")
const [activeId, setActiveId] = useState<string | null>(null) const [activeId, setActiveId] = useState<string | null>(null)
const [isCreatingTask, setIsCreatingTask] = useState(false) const [openSections, setOpenSections] = useState<Record<string, boolean>>({
current: true,
other: false,
backlog: true,
})
const [isCreatingSprint, setIsCreatingSprint] = useState(false)
const [newSprint, setNewSprint] = useState({
name: "",
goal: "",
startDate: "",
endDate: "",
})
// Sensors for drag detection // Sensors for drag detection
const sensors = useSensors( const sensors = useSensors(
@ -201,33 +209,28 @@ export function BacklogView() {
}) })
) )
// Get backlog tasks (backlog status, no sprint assigned) // Get current active sprint
const backlogTasks = tasks.filter( const now = new Date()
(t) => const currentSprint = sprints.find(
t.projectId === selectedProjectId && (s) =>
t.status === "backlog" && s.status === "active" &&
!t.sprintId new Date(s.startDate) <= now &&
new Date(s.endDate) >= now
) )
// Filter tasks // Get other sprints (not current)
const filteredTasks = backlogTasks.filter((task) => { const otherSprints = sprints.filter((s) => s.id !== currentSprint?.id)
const matchesSearch = task.title
.toLowerCase()
.includes(searchQuery.toLowerCase())
const matchesType = filterType === "all" || task.type === filterType
const matchesPriority =
filterPriority === "all" || task.priority === filterPriority
return matchesSearch && matchesType && matchesPriority
})
// Sort by priority (urgent > high > medium > low) // Get tasks by section
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 } const currentSprintTasks = currentSprint
const sortedTasks = [...filteredTasks].sort( ? tasks.filter((t) => t.sprintId === currentSprint.id)
(a, b) => priorityOrder[a.priority] - priorityOrder[b.priority] : []
const otherSprintsTasks = otherSprints.flatMap((sprint) =>
tasks.filter((t) => t.sprintId === sprint.id)
) )
// Get project sprints const backlogTasks = tasks.filter((t) => !t.sprintId)
const projectSprints = sprints.filter((s) => s.projectId === selectedProjectId)
// Get active task for drag overlay // Get active task for drag overlay
const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null
@ -242,27 +245,38 @@ export function BacklogView() {
if (!over) return if (!over) return
// Reordering logic would go here const taskId = active.id as string
// For now, just a placeholder for future implementation const overId = over.id as string
// If dropped over a section header, move task to that section's sprint
if (overId === "backlog") {
updateTask(taskId, { sprintId: undefined })
} else if (overId === "current" && currentSprint) {
updateTask(taskId, { sprintId: currentSprint.id })
} else if (overId.startsWith("sprint-")) {
const sprintId = overId.replace("sprint-", "")
updateTask(taskId, { sprintId })
}
} }
const handleAssignToSprint = (taskId: string, sprintId: string) => { const toggleSection = (section: string) => {
updateTask(taskId, { sprintId }) setOpenSections((prev) => ({ ...prev, [section]: !prev[section] }))
} }
const handleCreateTask = () => { const handleCreateSprint = () => {
if (!selectedProjectId) return if (!newSprint.name) return
addTask({ addSprint({
title: "New Backlog Item", name: newSprint.name,
description: "", goal: newSprint.goal,
type: "task", startDate: newSprint.startDate || new Date().toISOString(),
priority: "medium", endDate: newSprint.endDate || new Date().toISOString(),
status: "backlog", status: "planning",
projectId: selectedProjectId, projectId: selectedProjectId || "2",
tags: [],
}) })
setIsCreatingTask(false)
setIsCreatingSprint(false)
setNewSprint({ name: "", goal: "", startDate: "", endDate: "" })
} }
return ( return (
@ -273,161 +287,106 @@ export function BacklogView() {
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Current Sprint Section */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <div id="current">
<div className="flex items-center gap-2"> <TaskSection
<div className="relative"> title={currentSprint?.name || "Current Sprint"}
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" /> tasks={currentSprintTasks}
isOpen={openSections.current}
onToggle={() => toggleSection("current")}
onTaskClick={(task) => selectTask(task.id)}
sprintInfo={
currentSprint
? {
name: currentSprint.name,
date: `${format(new Date(currentSprint.startDate), "MMM d")} - ${format(
new Date(currentSprint.endDate),
"MMM d"
)}`,
status: currentSprint.status,
}
: undefined
}
/>
</div>
{/* Other Sprints Section */}
{otherSprints.length > 0 && (
<div id="other">
<TaskSection
title="Other Sprints"
tasks={otherSprintsTasks}
isOpen={openSections.other}
onToggle={() => toggleSection("other")}
onTaskClick={(task) => selectTask(task.id)}
/>
</div>
)}
{/* Create Sprint Button */}
{!isCreatingSprint ? (
<Button
variant="outline"
className="w-full border-dashed border-slate-700 text-slate-400 hover:text-white hover:border-slate-500"
onClick={() => setIsCreatingSprint(true)}
>
<Plus className="w-4 h-4 mr-2" />
Create Sprint
</Button>
) : (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4 space-y-3">
<h4 className="font-medium text-slate-200">Create New Sprint</h4>
<input <input
type="text" type="text"
placeholder="Search backlog..." placeholder="Sprint name"
value={searchQuery} value={newSprint.name}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setNewSprint({ ...newSprint, name: e.target.value })}
className="pl-9 pr-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-200 focus:outline-none focus:border-blue-500 w-full sm:w-64" className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white"
/> />
</div> <textarea
<select placeholder="Sprint goal (optional)"
value={filterType} value={newSprint.goal}
onChange={(e) => setFilterType(e.target.value)} onChange={(e) => setNewSprint({ ...newSprint, goal: e.target.value })}
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-200 focus:outline-none focus:border-blue-500" rows={2}
> className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white"
<option value="all">All Types</option> />
<option value="idea">💡 Idea</option> <div className="grid grid-cols-2 gap-3">
<option value="task">📋 Task</option> <input
<option value="bug">🐛 Bug</option> type="date"
<option value="research">🔬 Research</option> value={newSprint.startDate}
<option value="plan">📐 Plan</option> onChange={(e) => setNewSprint({ ...newSprint, startDate: e.target.value })}
</select> className="px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white"
<select
value={filterPriority}
onChange={(e) => setFilterPriority(e.target.value)}
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-200 focus:outline-none focus:border-blue-500"
>
<option value="all">All Priorities</option>
<option value="urgent">Urgent</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<Button onClick={handleCreateTask} size="sm">
<Plus className="w-4 h-4 mr-1" />
Add to Backlog
</Button>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm text-slate-400">
<span>
<strong className="text-slate-200">{backlogTasks.length}</strong> items in backlog
</span>
<span></span>
<span>
<strong className="text-slate-200">
{backlogTasks.filter((t) => t.priority === "urgent").length}
</strong>{" "}
urgent
</span>
<span></span>
<span>
<strong className="text-slate-200">
{backlogTasks.filter((t) => t.priority === "high").length}
</strong>{" "}
high priority
</span>
</div>
{/* Backlog List */}
{sortedTasks.length === 0 ? (
<div className="text-center py-12 text-slate-500">
{searchQuery || filterType !== "all" || filterPriority !== "all" ? (
<p>No items match your filters</p>
) : (
<>
<p className="mb-4">Backlog is empty</p>
<Button onClick={handleCreateTask} variant="outline">
<Plus className="w-4 h-4 mr-1" />
Add First Item
</Button>
</>
)}
</div>
) : (
<div className="space-y-2">
<SortableContext
items={sortedTasks.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
{sortedTasks.map((task) => (
<SortableBacklogItem
key={task.id}
task={task}
sprints={projectSprints}
onClick={() => selectTask(task.id)}
onAssignToSprint={(sprintId) =>
handleAssignToSprint(task.id, sprintId)
}
/> />
))} <input
</SortableContext> type="date"
</div> value={newSprint.endDate}
onChange={(e) => setNewSprint({ ...newSprint, endDate: e.target.value })}
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white"
/>
</div>
<div className="flex gap-2">
<Button onClick={handleCreateSprint} className="flex-1">
Create
</Button>
<Button variant="ghost" onClick={() => setIsCreatingSprint(false)}>
Cancel
</Button>
</div>
</CardContent>
</Card>
)} )}
{/* Sprint Planning Section */} {/* Backlog Section */}
{projectSprints.length > 0 && ( <div id="backlog">
<div className="mt-8 pt-6 border-t border-slate-800"> <TaskSection
<h3 className="text-lg font-medium text-slate-200 mb-4"> title="Backlog"
Sprint Planning tasks={backlogTasks}
</h3> isOpen={openSections.backlog}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> onToggle={() => toggleSection("backlog")}
{projectSprints.map((sprint) => { onTaskClick={(task) => selectTask(task.id)}
const sprintTasks = tasks.filter((t) => t.sprintId === sprint.id) />
const sprintPoints = sprintTasks.length // Simple count for now </div>
return (
<Card
key={sprint.id}
className="bg-slate-800 border-slate-700 cursor-pointer hover:border-blue-500 transition-colors"
onClick={() => {
// Switch to sprint view with this sprint selected
// This would need to be passed up to parent or use store
}}
>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-slate-200">
{sprint.name}
</h4>
<Badge
variant={
sprint.status === "active"
? "default"
: sprint.status === "completed"
? "secondary"
: "outline"
}
className="text-xs"
>
{sprint.status}
</Badge>
</div>
<p className="text-sm text-slate-400 line-clamp-2 mb-3">
{sprint.goal || "No goal set"}
</p>
<div className="flex items-center justify-between text-xs text-slate-500">
<span>{sprintTasks.length} tasks</span>
<span>
{new Date(sprint.startDate).toLocaleDateString()} -{" "}
{new Date(sprint.endDate).toLocaleDateString()}
</span>
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
)}
</div> </div>
<DragOverlay> <DragOverlay>