Add Backlog view for sprint planning
- Created BacklogView component with: - Search and filter (by type and priority) - List of unassigned backlog items - Quick sprint assignment dropdown - Sprint planning section showing all sprints - Drag-and-drop for reordering - Stats summary (total, urgent, high priority) - Quick add button for new backlog items - Added Backlog view toggle alongside Kanban/Sprint - Backlog items show type, priority, comment count
This commit is contained in:
parent
6f28828d5f
commit
0761adedbe
@ -87,7 +87,7 @@
|
||||
},
|
||||
{
|
||||
"id": "c7",
|
||||
"text": "\u2705 All 3 repos created and pushed to Gitea: gantt-board, blog-backup, heartbeat-monitor",
|
||||
"text": "✅ All 3 repos created and pushed to Gitea: gantt-board, blog-backup, heartbeat-monitor",
|
||||
"createdAt": "2026-02-18T17:01:23.109Z",
|
||||
"author": "assistant"
|
||||
}
|
||||
@ -469,5 +469,17 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": 1771529100000
|
||||
"lastUpdated": 1771544832422,
|
||||
"sprints": [
|
||||
{
|
||||
"name": "Sprint 1",
|
||||
"goal": "",
|
||||
"startDate": "2026-02-19",
|
||||
"endDate": "2026-02-22",
|
||||
"status": "planning",
|
||||
"projectId": "1",
|
||||
"id": "1771544832363",
|
||||
"createdAt": "2026-02-19T23:47:12.363Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -9,7 +9,8 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useTaskStore, Task, TaskType, TaskStatus, Priority, Project } from "@/stores/useTaskStore"
|
||||
import { SprintBoard } from "@/components/SprintBoard"
|
||||
import { Plus, MessageSquare, Calendar, Tag, Trash2, Edit2, X, Check, MoreHorizontal, LayoutGrid, Flag } from "lucide-react"
|
||||
import { BacklogView } from "@/components/BacklogView"
|
||||
import { Plus, MessageSquare, Calendar, Tag, Trash2, Edit2, X, Check, MoreHorizontal, LayoutGrid, Flag, ListTodo } from "lucide-react"
|
||||
|
||||
const typeColors: Record<TaskType, string> = {
|
||||
idea: "bg-purple-500",
|
||||
@ -71,7 +72,7 @@ export default function Home() {
|
||||
})
|
||||
const [newComment, setNewComment] = useState("")
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'kanban' | 'sprint'>('kanban')
|
||||
const [viewMode, setViewMode] = useState<'kanban' | 'sprint' | 'backlog'>('kanban')
|
||||
|
||||
// Sync from server on mount
|
||||
useEffect(() => {
|
||||
@ -267,6 +268,17 @@ export default function Home() {
|
||||
<Flag className="w-4 h-4" />
|
||||
Sprint
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('backlog')}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
viewMode === 'backlog'
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<ListTodo className="w-4 h-4" />
|
||||
Backlog
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={() => setNewTaskOpen(true)} className="w-full sm:w-auto">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
@ -278,6 +290,8 @@ export default function Home() {
|
||||
{/* View Content */}
|
||||
{viewMode === 'sprint' ? (
|
||||
<SprintBoard />
|
||||
) : viewMode === 'backlog' ? (
|
||||
<BacklogView />
|
||||
) : (
|
||||
<>
|
||||
{/* Kanban Columns */}
|
||||
|
||||
438
src/components/BacklogView.tsx
Normal file
438
src/components/BacklogView.tsx
Normal file
@ -0,0 +1,438 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCorners,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { useTaskStore, Task, Sprint } from "@/stores/useTaskStore"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Plus, GripVertical, Search, Filter } from "lucide-react"
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
low: "bg-slate-600",
|
||||
medium: "bg-blue-600",
|
||||
high: "bg-orange-600",
|
||||
urgent: "bg-red-600",
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
idea: "💡 Idea",
|
||||
task: "📋 Task",
|
||||
bug: "🐛 Bug",
|
||||
research: "🔬 Research",
|
||||
plan: "📐 Plan",
|
||||
}
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
idea: "bg-purple-500",
|
||||
task: "bg-blue-500",
|
||||
bug: "bg-red-500",
|
||||
research: "bg-green-500",
|
||||
plan: "bg-amber-500",
|
||||
}
|
||||
|
||||
// Sortable Backlog Item
|
||||
function SortableBacklogItem({
|
||||
task,
|
||||
sprints,
|
||||
onClick,
|
||||
onAssignToSprint,
|
||||
}: {
|
||||
task: Task
|
||||
sprints: Sprint[]
|
||||
onClick: () => void
|
||||
onAssignToSprint: (sprintId: string) => void
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: task.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
const currentSprint = sprints.find((s) => s.id === task.sprintId)
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="bg-slate-800 border-slate-700 hover:border-slate-600 transition-colors group"
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
{...attributes}
|
||||
{...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>
|
||||
|
||||
<div className="flex-1 min-w-0" onClick={onClick}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge
|
||||
className={`${typeColors[task.type]} text-white text-xs border-0`}
|
||||
>
|
||||
{typeLabels[task.type]}
|
||||
</Badge>
|
||||
<Badge
|
||||
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
|
||||
function DragOverlayItem({ task }: { task: Task }) {
|
||||
return (
|
||||
<Card className="bg-slate-800 border-slate-600 shadow-xl rotate-1">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical className="w-4 h-4 text-slate-500" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge
|
||||
className={`${typeColors[task.type]} text-white text-xs border-0`}
|
||||
>
|
||||
{typeLabels[task.type]}
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function BacklogView() {
|
||||
const {
|
||||
tasks,
|
||||
sprints,
|
||||
selectedProjectId,
|
||||
selectedTaskId,
|
||||
updateTask,
|
||||
selectTask,
|
||||
addTask,
|
||||
} = 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 [isCreatingTask, setIsCreatingTask] = useState(false)
|
||||
|
||||
// Sensors for drag detection
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// Get backlog tasks (backlog status, no sprint assigned)
|
||||
const backlogTasks = tasks.filter(
|
||||
(t) =>
|
||||
t.projectId === selectedProjectId &&
|
||||
t.status === "backlog" &&
|
||||
!t.sprintId
|
||||
)
|
||||
|
||||
// Filter tasks
|
||||
const filteredTasks = backlogTasks.filter((task) => {
|
||||
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)
|
||||
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 }
|
||||
const sortedTasks = [...filteredTasks].sort(
|
||||
(a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]
|
||||
)
|
||||
|
||||
// Get project sprints
|
||||
const projectSprints = sprints.filter((s) => s.projectId === selectedProjectId)
|
||||
|
||||
// Get active task for drag overlay
|
||||
const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string)
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
setActiveId(null)
|
||||
|
||||
if (!over) return
|
||||
|
||||
// Reordering logic would go here
|
||||
// For now, just a placeholder for future implementation
|
||||
}
|
||||
|
||||
const handleAssignToSprint = (taskId: string, sprintId: string) => {
|
||||
updateTask(taskId, { sprintId })
|
||||
}
|
||||
|
||||
const handleCreateTask = () => {
|
||||
if (!selectedProjectId) return
|
||||
|
||||
addTask({
|
||||
title: "New Backlog Item",
|
||||
description: "",
|
||||
type: "task",
|
||||
priority: "medium",
|
||||
status: "backlog",
|
||||
projectId: selectedProjectId,
|
||||
tags: [],
|
||||
})
|
||||
setIsCreatingTask(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search backlog..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(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"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(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 Types</option>
|
||||
<option value="idea">💡 Idea</option>
|
||||
<option value="task">📋 Task</option>
|
||||
<option value="bug">🐛 Bug</option>
|
||||
<option value="research">🔬 Research</option>
|
||||
<option value="plan">📐 Plan</option>
|
||||
</select>
|
||||
<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)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sprint Planning Section */}
|
||||
{projectSprints.length > 0 && (
|
||||
<div className="mt-8 pt-6 border-t border-slate-800">
|
||||
<h3 className="text-lg font-medium text-slate-200 mb-4">
|
||||
Sprint Planning
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{projectSprints.map((sprint) => {
|
||||
const sprintTasks = tasks.filter((t) => t.sprintId === sprint.id)
|
||||
const sprintPoints = sprintTasks.length // Simple count for now
|
||||
|
||||
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>
|
||||
|
||||
<DragOverlay>
|
||||
{activeTask ? <DragOverlayItem task={activeTask} /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user