- Add Projects page with Sprint Board and Backlog views - Copy SprintBoard and BacklogView components to components/gantt/ - Copy useTaskStore for project/task/sprint management - Add API routes for task persistence with SQLite - Add UI components: dialog, select, table, textarea - Add avatar and attachment utilities - Update sidebar with Projects navigation link - Remove static export config to support API routes - Add dist to .gitignore
426 lines
14 KiB
TypeScript
426 lines
14 KiB
TypeScript
"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, Calendar, Flag, GripVertical } from "lucide-react"
|
|
import { format, parseISO } from "date-fns"
|
|
|
|
const statusColumns = ["backlog", "in-progress", "review", "done"] as const
|
|
type SprintColumnStatus = typeof statusColumns[number]
|
|
|
|
const statusLabels: Record<string, string> = {
|
|
backlog: "To Do",
|
|
"in-progress": "In Progress",
|
|
review: "Review",
|
|
done: "Done",
|
|
}
|
|
|
|
const statusColors: Record<string, string> = {
|
|
backlog: "bg-slate-700",
|
|
"in-progress": "bg-blue-600",
|
|
review: "bg-yellow-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 && 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() {
|
|
const {
|
|
tasks,
|
|
sprints,
|
|
selectedSprintId,
|
|
selectSprint,
|
|
selectedProjectId,
|
|
updateTask,
|
|
selectTask,
|
|
addSprint,
|
|
} = useTaskStore()
|
|
|
|
const [isCreatingSprint, setIsCreatingSprint] = useState(false)
|
|
const [newSprint, setNewSprint] = useState({
|
|
name: "",
|
|
goal: "",
|
|
startDate: "",
|
|
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
|
|
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[]>)
|
|
|
|
// Get active task for drag overlay
|
|
const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null
|
|
|
|
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,
|
|
}
|
|
|
|
addSprint(sprint)
|
|
setIsCreatingSprint(false)
|
|
setNewSprint({ name: "", goal: "", startDate: "", endDate: "" })
|
|
}
|
|
|
|
const handleDragStart = (event: DragStartEvent) => {
|
|
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 SprintColumnStatus)) {
|
|
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) {
|
|
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">
|
|
<CardContent className="p-6 space-y-4">
|
|
<h3 className="text-lg font-medium text-slate-200">Create New Sprint</h3>
|
|
<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 (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCorners}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<div className="space-y-4">
|
|
{/* Sprint Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<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-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
|
{statusColumns.map((status) => (
|
|
<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 gap-2">
|
|
<div className={`w-2 h-2 rounded-full ${statusColors[status]}`} />
|
|
<h3 className="font-medium text-slate-300">{statusLabels[status]}</h3>
|
|
</div>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{tasksByStatus[status]?.length || 0}
|
|
</Badge>
|
|
</div>
|
|
<SortableContext
|
|
items={tasksByStatus[status]?.map((t) => t.id) || []}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div className="space-y-2 min-h-[100px]">
|
|
{tasksByStatus[status]?.map((task) => (
|
|
<SortableTaskCard
|
|
key={task.id}
|
|
task={task}
|
|
onClick={() => selectTask(task.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12 text-slate-500">
|
|
<p>Select a sprint to view the sprint board</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DragOverlay>
|
|
{activeTask ? <DragOverlayTaskCard task={activeTask} /> : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
)
|
|
}
|