497 lines
16 KiB
TypeScript
497 lines
16 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState, type ReactNode } from "react"
|
|
import {
|
|
DndContext,
|
|
DragEndEvent,
|
|
DragOverlay,
|
|
DragStartEvent,
|
|
PointerSensor,
|
|
useDroppable,
|
|
useSensor,
|
|
useSensors,
|
|
closestCorners,
|
|
} from "@dnd-kit/core"
|
|
import {
|
|
SortableContext,
|
|
verticalListSortingStrategy,
|
|
useSortable,
|
|
} from "@dnd-kit/sortable"
|
|
import { CSS } from "@dnd-kit/utilities"
|
|
import { useRouter } from "next/navigation"
|
|
import { useTaskStore, Task } 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, ChevronDown, ChevronRight, Calendar } from "lucide-react"
|
|
import { format, isValid, parseISO } from "date-fns"
|
|
import { generateAvatarDataUrl } from "@/lib/avatar"
|
|
|
|
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: "💡",
|
|
task: "📋",
|
|
bug: "🐛",
|
|
research: "🔬",
|
|
plan: "📐",
|
|
}
|
|
|
|
interface AssignableUser {
|
|
id: string
|
|
name: string
|
|
email?: string
|
|
avatarUrl?: string
|
|
}
|
|
|
|
function AssigneeAvatar({ name, avatarUrl, seed }: { name?: string; avatarUrl?: string; seed?: string }) {
|
|
const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "unassigned", name || "Unassigned")
|
|
return (
|
|
<img
|
|
src={displayUrl}
|
|
alt={name || "Assignee"}
|
|
className="h-6 w-6 rounded-full border border-slate-700 object-cover bg-slate-900"
|
|
title={name ? `Assigned to ${name}` : "Unassigned"}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Sortable Task Row
|
|
function SortableTaskRow({
|
|
task,
|
|
assigneeAvatarUrl,
|
|
onClick,
|
|
}: {
|
|
task: Task
|
|
assigneeAvatarUrl?: string
|
|
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 (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
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"
|
|
onClick={onClick}
|
|
>
|
|
<div
|
|
{...attributes}
|
|
{...listeners}
|
|
className="shrink-0 h-8 w-6 rounded border border-slate-700/70 bg-slate-900/70 flex items-center justify-center cursor-grab active:cursor-grabbing text-slate-400 hover:text-slate-200"
|
|
aria-label="Drag task"
|
|
title="Drag task"
|
|
>
|
|
<GripVertical className="w-4 h-4 text-slate-500" />
|
|
</div>
|
|
<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>
|
|
{task.comments && task.comments.length > 0 && (
|
|
<span className="text-xs text-slate-500">💬 {task.comments.length}</span>
|
|
)}
|
|
<AssigneeAvatar name={task.assigneeName} avatarUrl={assigneeAvatarUrl} seed={task.assigneeId} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Drag Overlay Item
|
|
function DragOverlayItem({ task, assigneeAvatarUrl }: { task: Task; assigneeAvatarUrl?: string }) {
|
|
return (
|
|
<div className="flex items-center gap-3 p-3 bg-slate-800 border border-slate-600 rounded-lg shadow-xl rotate-1">
|
|
<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>
|
|
<AssigneeAvatar name={task.assigneeName} avatarUrl={assigneeAvatarUrl} seed={task.assigneeId} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SectionDropZone({ id, children }: { id: string; children: ReactNode }) {
|
|
const { isOver, setNodeRef } = useDroppable({ id })
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
className={`rounded-lg transition-colors ${isOver ? "ring-1 ring-blue-500/60 bg-blue-500/5" : ""}`}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Collapsible Section
|
|
function TaskSection({
|
|
title,
|
|
tasks,
|
|
isOpen,
|
|
onToggle,
|
|
onTaskClick,
|
|
resolveAssigneeAvatar,
|
|
sprintInfo,
|
|
}: {
|
|
title: string
|
|
tasks: Task[]
|
|
isOpen: boolean
|
|
onToggle: () => void
|
|
onTaskClick: (task: Task) => void
|
|
resolveAssigneeAvatar: (task: Task) => string | undefined
|
|
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">
|
|
{isOpen ? (
|
|
<ChevronDown className="w-5 h-5 text-slate-400" />
|
|
) : (
|
|
<ChevronRight className="w-5 h-5 text-slate-400" />
|
|
)}
|
|
<h3 className="font-medium text-slate-200">{title}</h3>
|
|
<Badge variant="secondary" className="bg-slate-800 text-slate-400">
|
|
{tasks.length}
|
|
</Badge>
|
|
</div>
|
|
{sprintInfo && (
|
|
<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}
|
|
assigneeAvatarUrl={resolveAssigneeAvatar(task)}
|
|
onClick={() => onTaskClick(task)}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</SortableContext>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function BacklogView() {
|
|
const router = useRouter()
|
|
const [assignableUsers, setAssignableUsers] = useState<AssignableUser[]>([])
|
|
const {
|
|
tasks,
|
|
sprints,
|
|
updateTask,
|
|
addSprint,
|
|
} = useTaskStore()
|
|
|
|
useEffect(() => {
|
|
let active = true
|
|
const loadUsers = async () => {
|
|
try {
|
|
const response = await fetch("/api/auth/users", { cache: "no-store" })
|
|
if (!response.ok) return
|
|
const data = await response.json()
|
|
if (!active || !Array.isArray(data?.users)) return
|
|
setAssignableUsers(
|
|
data.users.map((entry: { id: string; name: string; email?: string; avatarUrl?: string }) => ({
|
|
id: entry.id,
|
|
name: entry.name,
|
|
email: entry.email,
|
|
avatarUrl: entry.avatarUrl,
|
|
})),
|
|
)
|
|
} catch {
|
|
// Keep backlog usable if users lookup fails.
|
|
}
|
|
}
|
|
void loadUsers()
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [])
|
|
|
|
const resolveAssigneeAvatar = (task: Task) => {
|
|
if (!task.assigneeId) return task.assigneeAvatarUrl
|
|
return assignableUsers.find((user) => user.id === task.assigneeId)?.avatarUrl || task.assigneeAvatarUrl
|
|
}
|
|
|
|
const [activeId, setActiveId] = useState<string | null>(null)
|
|
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
|
|
current: true,
|
|
backlog: true,
|
|
})
|
|
const [isCreatingSprint, setIsCreatingSprint] = useState(false)
|
|
const [newSprint, setNewSprint] = useState({
|
|
name: "",
|
|
goal: "",
|
|
startDate: "",
|
|
endDate: "",
|
|
})
|
|
|
|
// Sensors for drag detection
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: {
|
|
distance: 8,
|
|
},
|
|
})
|
|
)
|
|
|
|
// Get current active sprint
|
|
const now = new Date()
|
|
const currentSprint = sprints.find(
|
|
(s) =>
|
|
s.status === "active" &&
|
|
new Date(s.startDate) <= now &&
|
|
new Date(s.endDate) >= now
|
|
)
|
|
|
|
// Get other sprints (not current)
|
|
const otherSprints = sprints.filter((s) => s.id !== currentSprint?.id)
|
|
|
|
// Get tasks by section
|
|
const currentSprintTasks = currentSprint
|
|
? tasks.filter((t) => t.sprintId === currentSprint.id)
|
|
: []
|
|
|
|
const backlogTasks = tasks.filter((t) => !t.sprintId)
|
|
|
|
// 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
|
|
|
|
const taskId = active.id as string
|
|
const overId = over.id as string
|
|
const overTask = tasks.find((t) => t.id === overId)
|
|
|
|
const destinationId = overTask
|
|
? overTask.sprintId
|
|
? currentSprint && overTask.sprintId === currentSprint.id
|
|
? "current"
|
|
: `sprint-${overTask.sprintId}`
|
|
: "backlog"
|
|
: overId
|
|
|
|
// If dropped over a section header, move task to that section's sprint
|
|
if (destinationId === "backlog") {
|
|
updateTask(taskId, { sprintId: undefined, status: "open" })
|
|
} else if (destinationId === "current" && currentSprint) {
|
|
updateTask(taskId, { sprintId: currentSprint.id, status: "open" })
|
|
} else if (destinationId.startsWith("sprint-")) {
|
|
const sprintId = destinationId.replace("sprint-", "")
|
|
updateTask(taskId, { sprintId, status: "open" })
|
|
}
|
|
}
|
|
|
|
const toggleSection = (section: string) => {
|
|
setOpenSections((prev) => ({ ...prev, [section]: !prev[section] }))
|
|
}
|
|
|
|
const handleCreateSprint = () => {
|
|
if (!newSprint.name) return
|
|
|
|
addSprint({
|
|
name: newSprint.name,
|
|
goal: newSprint.goal,
|
|
startDate: newSprint.startDate || new Date().toISOString(),
|
|
endDate: newSprint.endDate || new Date().toISOString(),
|
|
status: "planning",
|
|
})
|
|
|
|
setIsCreatingSprint(false)
|
|
setNewSprint({ name: "", goal: "", startDate: "", endDate: "" })
|
|
}
|
|
|
|
return (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCorners}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<div className="space-y-4">
|
|
{/* Current Sprint Section */}
|
|
<SectionDropZone id="current">
|
|
<TaskSection
|
|
title={currentSprint?.name || "Current Sprint"}
|
|
tasks={currentSprintTasks}
|
|
isOpen={openSections.current}
|
|
onToggle={() => toggleSection("current")}
|
|
onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
|
|
resolveAssigneeAvatar={resolveAssigneeAvatar}
|
|
sprintInfo={
|
|
currentSprint
|
|
? {
|
|
name: currentSprint.name,
|
|
date: `${(() => {
|
|
const start = parseISO(currentSprint.startDate)
|
|
const end = parseISO(currentSprint.endDate)
|
|
if (!isValid(start) || !isValid(end)) return "Invalid dates"
|
|
return `${format(start, "MMM d")} - ${format(end, "MMM d")}`
|
|
})()}`,
|
|
status: currentSprint.status,
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
</SectionDropZone>
|
|
|
|
{/* Other Sprints Sections - ordered by start date */}
|
|
{otherSprints
|
|
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())
|
|
.map((sprint) => {
|
|
const sprintTasks = tasks.filter((t) => t.sprintId === sprint.id)
|
|
console.log(`Sprint ${sprint.name}: ${sprintTasks.length} tasks`, sprintTasks.map(t => t.title))
|
|
return (
|
|
<SectionDropZone key={sprint.id} id={`sprint-${sprint.id}`}>
|
|
<TaskSection
|
|
title={sprint.name}
|
|
tasks={sprintTasks}
|
|
isOpen={openSections[sprint.id] ?? false}
|
|
onToggle={() => toggleSection(sprint.id)}
|
|
onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
|
|
resolveAssigneeAvatar={resolveAssigneeAvatar}
|
|
sprintInfo={{
|
|
name: sprint.name,
|
|
date: (() => {
|
|
const start = parseISO(sprint.startDate)
|
|
const end = parseISO(sprint.endDate)
|
|
if (!isValid(start) || !isValid(end)) return "Invalid dates"
|
|
return `${format(start, "MMM d")} - ${format(end, "MMM d")}`
|
|
})(),
|
|
status: sprint.status,
|
|
}}
|
|
/>
|
|
</SectionDropZone>
|
|
)
|
|
})}
|
|
|
|
{/* 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
|
|
type="text"
|
|
placeholder="Sprint name"
|
|
value={newSprint.name}
|
|
onChange={(e) => setNewSprint({ ...newSprint, name: e.target.value })}
|
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white"
|
|
/>
|
|
<textarea
|
|
placeholder="Sprint goal (optional)"
|
|
value={newSprint.goal}
|
|
onChange={(e) => setNewSprint({ ...newSprint, goal: e.target.value })}
|
|
rows={2}
|
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white"
|
|
/>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<input
|
|
type="date"
|
|
value={newSprint.startDate}
|
|
onChange={(e) => setNewSprint({ ...newSprint, startDate: e.target.value })}
|
|
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white"
|
|
/>
|
|
<input
|
|
type="date"
|
|
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>
|
|
)}
|
|
|
|
{/* Backlog Section */}
|
|
<SectionDropZone id="backlog">
|
|
<TaskSection
|
|
title="Backlog"
|
|
tasks={backlogTasks}
|
|
isOpen={openSections.backlog}
|
|
onToggle={() => toggleSection("backlog")}
|
|
onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
|
|
resolveAssigneeAvatar={resolveAssigneeAvatar}
|
|
/>
|
|
</SectionDropZone>
|
|
</div>
|
|
|
|
<DragOverlay>
|
|
{activeTask ? <DragOverlayItem task={activeTask} assigneeAvatarUrl={resolveAssigneeAvatar(activeTask)} /> : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
)
|
|
}
|