Add drag-and-drop and sprint assignment
- Integrated @dnd-kit for drag-and-drop in Sprint Board - Tasks can be dragged between columns (To Do → In Progress → Review → Done) - Added sprint selector to new task dialog - Added sprint selector to task detail view - Visual drag overlay during drag operations - Grip handle appears on hover for drag initiation
This commit is contained in:
parent
29ce1cce2a
commit
6f28828d5f
56
package-lock.json
generated
56
package-lock.json
generated
@ -9,6 +9,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
@ -296,6 +299,59 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
||||||
|
|||||||
@ -9,6 +9,9 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
|||||||
@ -40,8 +40,10 @@ export default function Home() {
|
|||||||
const {
|
const {
|
||||||
projects,
|
projects,
|
||||||
tasks,
|
tasks,
|
||||||
|
sprints,
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
selectedTaskId,
|
selectedTaskId,
|
||||||
|
selectedSprintId,
|
||||||
selectProject,
|
selectProject,
|
||||||
addProject,
|
addProject,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
@ -416,7 +418,7 @@ export default function Home() {
|
|||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label>Type</Label>
|
<Label>Type</Label>
|
||||||
<select
|
<select
|
||||||
@ -442,6 +444,8 @@ export default function Home() {
|
|||||||
<option value="urgent">Urgent</option>
|
<option value="urgent">Urgent</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label>Status</Label>
|
<Label>Status</Label>
|
||||||
<select
|
<select
|
||||||
@ -454,6 +458,19 @@ export default function Home() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Sprint (optional)</Label>
|
||||||
|
<select
|
||||||
|
value={newTask.sprintId || ""}
|
||||||
|
onChange={(e) => setNewTask({ ...newTask, sprintId: e.target.value || undefined })}
|
||||||
|
className="w-full mt-1.5 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">No Sprint</option>
|
||||||
|
{sprints.filter(s => s.projectId === selectedProjectId).map((sprint) => (
|
||||||
|
<option key={sprint.id} value={sprint.id}>{sprint.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Tags (comma separated)</Label>
|
<Label>Tags (comma separated)</Label>
|
||||||
@ -563,6 +580,21 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sprint */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-slate-400">Sprint</Label>
|
||||||
|
<select
|
||||||
|
value={selectedTask.sprintId || ""}
|
||||||
|
onChange={(e) => updateTask(selectedTask.id, { sprintId: e.target.value || undefined })}
|
||||||
|
className="w-full mt-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">No Sprint</option>
|
||||||
|
{sprints.filter(s => s.projectId === selectedProjectId).map((sprint) => (
|
||||||
|
<option key={sprint.id} value={sprint.id}>{sprint.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Comments Section */}
|
{/* Comments Section */}
|
||||||
<div className="border-t border-slate-800 pt-6">
|
<div className="border-t border-slate-800 pt-6">
|
||||||
<h4 className="font-medium text-white mb-4 flex items-center gap-2">
|
<h4 className="font-medium text-white mb-4 flex items-center gap-2">
|
||||||
|
|||||||
@ -1,11 +1,27 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
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 { useTaskStore, Task, Sprint } from "@/stores/useTaskStore"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent } 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, Calendar, Flag } from "lucide-react"
|
import { Plus, Calendar, Flag, GripVertical } from "lucide-react"
|
||||||
import { format, parseISO } from "date-fns"
|
import { format, parseISO } from "date-fns"
|
||||||
|
|
||||||
const statusColumns = ["backlog", "in-progress", "review", "done"] as const
|
const statusColumns = ["backlog", "in-progress", "review", "done"] as const
|
||||||
@ -18,12 +34,114 @@ const statusLabels: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
backlog: "bg-zinc-700",
|
backlog: "bg-slate-700",
|
||||||
"in-progress": "bg-blue-600",
|
"in-progress": "bg-blue-600",
|
||||||
review: "bg-yellow-600",
|
review: "bg-yellow-600",
|
||||||
done: "bg-green-600",
|
done: "bg-green-600",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
low: "bg-slate-600",
|
||||||
|
medium: "bg-blue-600",
|
||||||
|
high: "bg-orange-600",
|
||||||
|
urgent: "bg-red-600",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortable Task Card Component
|
||||||
|
function SortableTaskCard({
|
||||||
|
task,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
task: Task
|
||||||
|
onClick: () => 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="bg-slate-800 border-slate-700 cursor-pointer hover:border-slate-600 transition-colors group"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="mt-0.5 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">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h4 className="text-sm font-medium text-slate-200 line-clamp-2">
|
||||||
|
{task.title}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Badge
|
||||||
|
className={`${priorityColors[task.priority]} text-white text-xs`}
|
||||||
|
>
|
||||||
|
{task.priority}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
|
||||||
|
{task.type}
|
||||||
|
</Badge>
|
||||||
|
{task.comments.length > 0 && (
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
💬 {task.comments.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag Overlay Task Card (shown while dragging)
|
||||||
|
function DragOverlayTaskCard({ task }: { task: Task }) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-slate-800 border-slate-600 shadow-xl rotate-2">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<GripVertical className="w-4 h-4 text-slate-500 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-sm font-medium text-slate-200 line-clamp-2">
|
||||||
|
{task.title}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Badge
|
||||||
|
className={`${priorityColors[task.priority]} text-white text-xs`}
|
||||||
|
>
|
||||||
|
{task.priority}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
|
||||||
|
{task.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function SprintBoard() {
|
export function SprintBoard() {
|
||||||
const {
|
const {
|
||||||
tasks,
|
tasks,
|
||||||
@ -31,7 +149,10 @@ export function SprintBoard() {
|
|||||||
selectedSprintId,
|
selectedSprintId,
|
||||||
selectSprint,
|
selectSprint,
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
|
selectedTaskId,
|
||||||
updateTask,
|
updateTask,
|
||||||
|
selectTask,
|
||||||
|
addSprint,
|
||||||
} = useTaskStore()
|
} = useTaskStore()
|
||||||
|
|
||||||
const [isCreatingSprint, setIsCreatingSprint] = useState(false)
|
const [isCreatingSprint, setIsCreatingSprint] = useState(false)
|
||||||
@ -41,6 +162,16 @@ export function SprintBoard() {
|
|||||||
startDate: "",
|
startDate: "",
|
||||||
endDate: "",
|
endDate: "",
|
||||||
})
|
})
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Sensors for drag detection
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// Get sprints for selected project
|
// Get sprints for selected project
|
||||||
const projectSprints = sprints.filter(
|
const projectSprints = sprints.filter(
|
||||||
@ -59,6 +190,9 @@ export function SprintBoard() {
|
|||||||
return acc
|
return acc
|
||||||
}, {} as Record<string, Task[]>)
|
}, {} as Record<string, Task[]>)
|
||||||
|
|
||||||
|
// Get active task for drag overlay
|
||||||
|
const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null
|
||||||
|
|
||||||
const handleCreateSprint = () => {
|
const handleCreateSprint = () => {
|
||||||
if (!newSprint.name || !selectedProjectId) return
|
if (!newSprint.name || !selectedProjectId) return
|
||||||
|
|
||||||
@ -71,16 +205,35 @@ export function SprintBoard() {
|
|||||||
projectId: selectedProjectId,
|
projectId: selectedProjectId,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the store's addSprint action
|
|
||||||
const { addSprint } = useTaskStore.getState()
|
|
||||||
addSprint(sprint)
|
addSprint(sprint)
|
||||||
|
|
||||||
setIsCreatingSprint(false)
|
setIsCreatingSprint(false)
|
||||||
setNewSprint({ name: "", goal: "", startDate: "", endDate: "" })
|
setNewSprint({ name: "", goal: "", startDate: "", endDate: "" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMoveTask = (taskId: string, newStatus: string) => {
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
updateTask(taskId, { status: newStatus as any })
|
setActiveId(event.active.id as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event
|
||||||
|
setActiveId(null)
|
||||||
|
|
||||||
|
if (!over) return
|
||||||
|
|
||||||
|
const taskId = active.id as string
|
||||||
|
const overId = over.id as string
|
||||||
|
|
||||||
|
// Check if dropped over a column
|
||||||
|
if (statusColumns.includes(overId as any)) {
|
||||||
|
updateTask(taskId, { status: overId as Task["status"] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if dropped over another task
|
||||||
|
const overTask = tasks.find((t) => t.id === overId)
|
||||||
|
if (overTask && overTask.status !== tasks.find((t) => t.id === taskId)?.status) {
|
||||||
|
updateTask(taskId, { status: overTask.status })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (projectSprints.length === 0 && !isCreatingSprint) {
|
if (projectSprints.length === 0 && !isCreatingSprint) {
|
||||||
@ -101,10 +254,8 @@ export function SprintBoard() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto py-8">
|
<div className="max-w-md mx-auto py-8">
|
||||||
<Card className="bg-slate-900 border-slate-800">
|
<Card className="bg-slate-900 border-slate-800">
|
||||||
<CardHeader>
|
<CardContent className="p-6 space-y-4">
|
||||||
<CardTitle className="text-lg">Create New Sprint</CardTitle>
|
<h3 className="text-lg font-medium text-slate-200">Create New Sprint</h3>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-slate-400">Sprint Name</label>
|
<label className="text-sm text-slate-400">Sprint Name</label>
|
||||||
<input
|
<input
|
||||||
@ -160,10 +311,16 @@ export function SprintBoard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCorners}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Sprint Header */}
|
{/* Sprint Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<select
|
<select
|
||||||
value={selectedSprintId || ""}
|
value={selectedSprintId || ""}
|
||||||
onChange={(e) => selectSprint(e.target.value || null)}
|
onChange={(e) => selectSprint(e.target.value || null)}
|
||||||
@ -219,48 +376,37 @@ export function SprintBoard() {
|
|||||||
|
|
||||||
{/* Sprint Board */}
|
{/* Sprint Board */}
|
||||||
{selectedSprintId ? (
|
{selectedSprintId ? (
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||||
{statusColumns.map((status) => (
|
{statusColumns.map((status) => (
|
||||||
<div key={status} className="flex flex-col">
|
<div
|
||||||
|
key={status}
|
||||||
|
id={status}
|
||||||
|
className="flex flex-col bg-slate-900/50 rounded-lg p-3"
|
||||||
|
data-status={status}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${statusColors[status]}`} />
|
||||||
<h3 className="font-medium text-slate-300">{statusLabels[status]}</h3>
|
<h3 className="font-medium text-slate-300">{statusLabels[status]}</h3>
|
||||||
|
</div>
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{tasksByStatus[status]?.length || 0}
|
{tasksByStatus[status]?.length || 0}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<SortableContext
|
||||||
|
items={tasksByStatus[status]?.map((t) => t.id) || []}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-2 min-h-[100px]">
|
||||||
{tasksByStatus[status]?.map((task) => (
|
{tasksByStatus[status]?.map((task) => (
|
||||||
<Card
|
<SortableTaskCard
|
||||||
key={task.id}
|
key={task.id}
|
||||||
className="bg-slate-800 border-slate-700 cursor-pointer hover:border-slate-600 transition-colors"
|
task={task}
|
||||||
draggable
|
onClick={() => selectTask(task.id)}
|
||||||
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>
|
||||||
|
</SortableContext>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -270,5 +416,10 @@ export function SprintBoard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeTask ? <DragOverlayTaskCard task={activeTask} /> : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user