mission-control/components/gantt/BacklogView.tsx
OpenClaw Bot c1c01bd21e feat: merge Gantt Board into Mission Control
- 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
2026-02-20 18:49:52 -06:00

499 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,
selectedProjectId,
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",
projectId: selectedProjectId || "2",
})
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>
)
}