Add Sprint Board UI component
- Created SprintBoard component with: - Sprint selector dropdown - Sprint creation form - Sprint board with To Do/In Progress/Review/Done columns - Drag-and-drop ready task cards - Sprint goal display - Date range and status badges - Added view toggle (Kanban/Sprint) to main page - Sprint data persisted to server
This commit is contained in:
parent
7710167eb2
commit
29ce1cce2a
@ -8,7 +8,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useTaskStore, Task, TaskType, TaskStatus, Priority, Project } from "@/stores/useTaskStore"
|
||||
import { Plus, MessageSquare, Calendar, Tag, Trash2, Edit2, X, Check, MoreHorizontal } from "lucide-react"
|
||||
import { SprintBoard } from "@/components/SprintBoard"
|
||||
import { Plus, MessageSquare, Calendar, Tag, Trash2, Edit2, X, Check, MoreHorizontal, LayoutGrid, Flag } from "lucide-react"
|
||||
|
||||
const typeColors: Record<TaskType, string> = {
|
||||
idea: "bg-purple-500",
|
||||
@ -68,6 +69,7 @@ export default function Home() {
|
||||
})
|
||||
const [newComment, setNewComment] = useState("")
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'kanban' | 'sprint'>('kanban')
|
||||
|
||||
// Sync from server on mount
|
||||
useEffect(() => {
|
||||
@ -225,7 +227,7 @@ export default function Home() {
|
||||
</Card>
|
||||
</aside>
|
||||
|
||||
{/* Main Content - Kanban Board */}
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 min-w-0">
|
||||
{selectedProject ? (
|
||||
<>
|
||||
@ -238,13 +240,45 @@ export default function Home() {
|
||||
{projectTasks.length} tasks · {projectTasks.filter((t) => t.status === "done").length} done
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setNewTaskOpen(true)} className="w-full sm:w-auto">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Task
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View Toggle */}
|
||||
<div className="flex bg-slate-800 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('kanban')}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
viewMode === 'kanban'
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
Kanban
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('sprint')}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
viewMode === 'sprint'
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Flag className="w-4 h-4" />
|
||||
Sprint
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={() => setNewTaskOpen(true)} className="w-full sm:w-auto">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kanban Columns */}
|
||||
{/* View Content */}
|
||||
{viewMode === 'sprint' ? (
|
||||
<SprintBoard />
|
||||
) : (
|
||||
<>
|
||||
{/* Kanban Columns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{statusColumns.map((status) => {
|
||||
const columnTasks = projectTasks.filter((t) => t.status === status)
|
||||
@ -343,6 +377,8 @@ export default function Home() {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-96 text-slate-500">
|
||||
|
||||
274
src/components/SprintBoard.tsx
Normal file
274
src/components/SprintBoard.tsx
Normal file
@ -0,0 +1,274 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTaskStore, Task, Sprint } from "@/stores/useTaskStore"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Plus, Calendar, Flag } from "lucide-react"
|
||||
import { format, parseISO } from "date-fns"
|
||||
|
||||
const statusColumns = ["backlog", "in-progress", "review", "done"] as const
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
backlog: "To Do",
|
||||
"in-progress": "In Progress",
|
||||
review: "Review",
|
||||
done: "Done",
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
backlog: "bg-zinc-700",
|
||||
"in-progress": "bg-blue-600",
|
||||
review: "bg-yellow-600",
|
||||
done: "bg-green-600",
|
||||
}
|
||||
|
||||
export function SprintBoard() {
|
||||
const {
|
||||
tasks,
|
||||
sprints,
|
||||
selectedSprintId,
|
||||
selectSprint,
|
||||
selectedProjectId,
|
||||
updateTask,
|
||||
} = useTaskStore()
|
||||
|
||||
const [isCreatingSprint, setIsCreatingSprint] = useState(false)
|
||||
const [newSprint, setNewSprint] = useState({
|
||||
name: "",
|
||||
goal: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
})
|
||||
|
||||
// Get sprints for selected project
|
||||
const projectSprints = sprints.filter(
|
||||
(s) => s.projectId === selectedProjectId
|
||||
)
|
||||
|
||||
// Get current sprint
|
||||
const currentSprint = sprints.find((s) => s.id === selectedSprintId)
|
||||
|
||||
// Get tasks for current sprint
|
||||
const sprintTasks = tasks.filter((t) => t.sprintId === selectedSprintId)
|
||||
|
||||
// Group tasks by status
|
||||
const tasksByStatus = statusColumns.reduce((acc, status) => {
|
||||
acc[status] = sprintTasks.filter((t) => t.status === status)
|
||||
return acc
|
||||
}, {} as Record<string, Task[]>)
|
||||
|
||||
const handleCreateSprint = () => {
|
||||
if (!newSprint.name || !selectedProjectId) return
|
||||
|
||||
const sprint: Omit<Sprint, "id" | "createdAt"> = {
|
||||
name: newSprint.name,
|
||||
goal: newSprint.goal,
|
||||
startDate: newSprint.startDate || new Date().toISOString(),
|
||||
endDate: newSprint.endDate || new Date().toISOString(),
|
||||
status: "planning",
|
||||
projectId: selectedProjectId,
|
||||
}
|
||||
|
||||
// Use the store's addSprint action
|
||||
const { addSprint } = useTaskStore.getState()
|
||||
addSprint(sprint)
|
||||
|
||||
setIsCreatingSprint(false)
|
||||
setNewSprint({ name: "", goal: "", startDate: "", endDate: "" })
|
||||
}
|
||||
|
||||
const handleMoveTask = (taskId: string, newStatus: string) => {
|
||||
updateTask(taskId, { status: newStatus as any })
|
||||
}
|
||||
|
||||
if (projectSprints.length === 0 && !isCreatingSprint) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-zinc-400">
|
||||
<Flag className="w-12 h-12 mb-4 text-zinc-600" />
|
||||
<h3 className="text-lg font-medium text-zinc-300 mb-2">No Sprints Yet</h3>
|
||||
<p className="text-sm mb-4">Create your first sprint to start organizing work</p>
|
||||
<Button onClick={() => setIsCreatingSprint(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Sprint
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isCreatingSprint) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto py-8">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Create New Sprint</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-slate-400">Sprint Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSprint.name}
|
||||
onChange={(e) => setNewSprint({ ...newSprint, name: e.target.value })}
|
||||
placeholder="e.g., Sprint 1 - Foundation"
|
||||
className="w-full mt-1 bg-slate-800 border border-slate-700 rounded px-3 py-2 text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-slate-400">Sprint Goal</label>
|
||||
<textarea
|
||||
value={newSprint.goal}
|
||||
onChange={(e) => setNewSprint({ ...newSprint, goal: e.target.value })}
|
||||
placeholder="What do you want to achieve?"
|
||||
rows={2}
|
||||
className="w-full mt-1 bg-slate-800 border border-slate-700 rounded px-3 py-2 text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-slate-400">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newSprint.startDate}
|
||||
onChange={(e) => setNewSprint({ ...newSprint, startDate: e.target.value })}
|
||||
className="w-full mt-1 bg-slate-800 border border-slate-700 rounded px-3 py-2 text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-slate-400">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newSprint.endDate}
|
||||
onChange={(e) => setNewSprint({ ...newSprint, endDate: e.target.value })}
|
||||
className="w-full mt-1 bg-slate-800 border border-slate-700 rounded px-3 py-2 text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button onClick={handleCreateSprint} className="flex-1">
|
||||
Create Sprint
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setIsCreatingSprint(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Sprint Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={selectedSprintId || ""}
|
||||
onChange={(e) => selectSprint(e.target.value || null)}
|
||||
className="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-slate-100"
|
||||
>
|
||||
<option value="">Select a sprint...</option>
|
||||
{projectSprints.map((sprint) => (
|
||||
<option key={sprint.id} value={sprint.id}>
|
||||
{sprint.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsCreatingSprint(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
New Sprint
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{currentSprint && (
|
||||
<div className="flex items-center gap-4 text-sm text-slate-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>
|
||||
{format(parseISO(currentSprint.startDate), "MMM d")} -{" "}
|
||||
{format(parseISO(currentSprint.endDate), "MMM d, yyyy")}
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
currentSprint.status === "active"
|
||||
? "default"
|
||||
: currentSprint.status === "completed"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{currentSprint.status}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sprint Goal */}
|
||||
{currentSprint?.goal && (
|
||||
<div className="bg-slate-800/50 border border-slate-700 rounded-lg p-3 text-sm text-slate-300">
|
||||
<span className="font-medium text-slate-200">Goal:</span> {currentSprint.goal}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sprint Board */}
|
||||
{selectedSprintId ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{statusColumns.map((status) => (
|
||||
<div key={status} className="flex flex-col">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium text-slate-300">{statusLabels[status]}</h3>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{tasksByStatus[status]?.length || 0}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{tasksByStatus[status]?.map((task) => (
|
||||
<Card
|
||||
key={task.id}
|
||||
className="bg-slate-800 border-slate-700 cursor-pointer hover:border-slate-600 transition-colors"
|
||||
draggable
|
||||
onDragEnd={() => {}}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="text-sm font-medium text-slate-200 line-clamp-2">
|
||||
{task.title}
|
||||
</h4>
|
||||
<Badge
|
||||
className={`${statusColors[task.priority]} text-white text-xs shrink-0`}
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{task.type}
|
||||
</Badge>
|
||||
{task.comments.length > 0 && (
|
||||
<span className="text-xs text-slate-500">
|
||||
💬 {task.comments.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<p>Select a sprint to view the sprint board</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user