gantt-board/src/app/page.tsx

1104 lines
43 KiB
TypeScript

"use client"
import { useState, useEffect, useMemo, type ReactNode } from "react"
import {
DndContext,
DragEndEvent,
DragOverlay,
DragOverEvent,
DragStartEvent,
PointerSensor,
useDraggable,
useDroppable,
useSensor,
useSensors,
closestCorners,
} from "@dnd-kit/core"
import { CSS } from "@dnd-kit/utilities"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { useTaskStore, Task, TaskType, TaskStatus, Priority } from "@/stores/useTaskStore"
import { BacklogView } from "@/components/BacklogView"
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical } from "lucide-react"
const typeColors: Record<TaskType, string> = {
idea: "bg-purple-500",
task: "bg-blue-500",
bug: "bg-red-500",
research: "bg-green-500",
plan: "bg-amber-500",
}
const typeLabels: Record<TaskType, string> = {
idea: "💡 Idea",
task: "📋 Task",
bug: "🐛 Bug",
research: "🔬 Research",
plan: "📐 Plan",
}
const priorityColors: Record<Priority, string> = {
low: "text-slate-400",
medium: "text-blue-400",
high: "text-orange-400",
urgent: "text-red-400",
}
const allStatuses: TaskStatus[] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"]
// Sprint board columns mapped to workflow statuses
const sprintColumns: { key: string; label: string; statuses: TaskStatus[] }[] = [
{
key: "todo",
label: "To Do",
statuses: ["open", "todo"]
},
{
key: "inprogress",
label: "In Progress",
statuses: ["blocked", "in-progress", "review", "validate"]
},
{
key: "done",
label: "Done",
statuses: ["archived", "canceled", "done"]
},
]
const sprintColumnDropStatus: Record<string, TaskStatus> = {
todo: "open",
inprogress: "in-progress",
done: "done",
}
const formatStatusLabel = (status: TaskStatus) =>
status === "todo"
? "To Do"
:
status
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ")
function KanbanStatusDropTarget({
status,
count,
expanded = false,
}: {
status: TaskStatus
count: number
expanded?: boolean
}) {
const { isOver, setNodeRef } = useDroppable({ id: `kanban-status-${status}` })
return (
<div
ref={setNodeRef}
className={`rounded-md border transition-colors ${
expanded ? "px-3 py-3 text-xs" : "px-2 py-1 text-[11px]"
} ${
isOver
? "border-blue-400/70 bg-blue-500/20 text-blue-200"
: "border-slate-700 text-slate-400 bg-slate-900/40"
}`}
title={`Drop to set status: ${formatStatusLabel(status)}`}
>
{formatStatusLabel(status)} ({count})
</div>
)
}
function KanbanDropColumn({
id,
label,
count,
statusTargets,
isDragging,
isActiveDropColumn,
children,
}: {
id: string
label: string
count: number
statusTargets: Array<{ status: TaskStatus; count: number }>
isDragging: boolean
isActiveDropColumn: boolean
children: ReactNode
}) {
const { isOver, setNodeRef } = useDroppable({ id })
return (
<div className="flex flex-col">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-slate-400">{label}</h3>
<Badge variant="secondary" className="bg-slate-800 text-slate-400">
{count}
</Badge>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{statusTargets.map((target) => (
<KanbanStatusDropTarget
key={target.status}
status={target.status}
count={target.count}
/>
))}
</div>
<div
ref={setNodeRef}
className={`space-y-3 min-h-32 rounded-lg p-2 transition-colors ${
isOver ? "bg-blue-500/10 ring-1 ring-blue-500/50" : ""
}`}
>
{isDragging && isActiveDropColumn ? (
<div className="space-y-2">
<p className="text-xs text-slate-400 px-1">Drop into an exact status:</p>
{statusTargets.map((target) => (
<KanbanStatusDropTarget
key={`expanded-${target.status}`}
status={target.status}
count={target.count}
expanded
/>
))}
</div>
) : (
children
)}
</div>
</div>
)
}
function KanbanTaskCard({
task,
taskTags,
onOpen,
onDelete,
}: {
task: Task
taskTags: string[]
onOpen: () => void
onDelete: () => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useDraggable({
id: task.id,
})
const style = {
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
}
return (
<Card
ref={setNodeRef}
style={style}
className="bg-slate-900 border-slate-800 hover:border-slate-700 cursor-pointer transition-colors group"
onClick={onOpen}
>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="h-7 w-6 shrink-0 rounded border border-slate-700/80 bg-slate-800/70 flex items-center justify-center text-slate-400 hover:text-slate-200 cursor-grab active:cursor-grabbing"
title="Drag task"
aria-label="Drag task"
{...attributes}
{...listeners}
>
<GripVertical className="w-3.5 h-3.5" />
</button>
<Badge
variant="outline"
className={`text-xs ${typeColors[task.type]} text-white border-0`}
>
{typeLabels[task.type]}
</Badge>
</div>
<button
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className="p-1 hover:bg-slate-800 rounded"
>
<Trash2 className="w-3 h-3 text-slate-400" />
</button>
</div>
<h4 className="font-medium text-white mb-1">{task.title}</h4>
{task.description && (
<p className="text-sm text-slate-400 line-clamp-2 mb-3">
{task.description}
</p>
)}
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className={priorityColors[task.priority]}>
{task.priority}
</span>
{task.comments && task.comments.length > 0 && (
<span className="flex items-center gap-1 text-slate-500">
<MessageSquare className="w-3 h-3" />
{task.comments.length}
</span>
)}
</div>
{task.dueDate && (
<span className="text-slate-500 flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(task.dueDate).toLocaleDateString()}
</span>
)}
</div>
{taskTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{taskTags.map((tag) => (
<span
key={tag}
className="text-xs px-2 py-0.5 bg-slate-800 text-slate-400 rounded"
>
{tag}
</span>
))}
</div>
)}
</CardContent>
</Card>
)
}
export default function Home() {
console.log('>>> PAGE: Component rendering')
const {
projects,
tasks,
sprints,
selectedProjectId,
selectedTaskId,
addTask,
updateTask,
deleteTask,
selectTask,
addComment,
deleteComment,
syncFromServer,
isLoading,
} = useTaskStore()
const [newTaskOpen, setNewTaskOpen] = useState(false)
const [newTask, setNewTask] = useState<Partial<Task>>({
title: "",
description: "",
type: "task",
priority: "medium",
status: "open",
tags: [],
})
const [newComment, setNewComment] = useState("")
const [viewMode, setViewMode] = useState<'kanban' | 'backlog'>('kanban')
const [editedTask, setEditedTask] = useState<Task | null>(null)
const [newTaskLabelInput, setNewTaskLabelInput] = useState("")
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
const [activeKanbanTaskId, setActiveKanbanTaskId] = useState<string | null>(null)
const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState<string | null>(null)
const getTags = (taskLike: { tags?: unknown }) => {
if (!Array.isArray(taskLike.tags)) return [] as string[]
return taskLike.tags.filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0)
}
const labelUsage = useMemo(() => {
const counts = new Map<string, number>()
tasks.forEach((task) => {
getTags(task).forEach((label) => {
counts.set(label, (counts.get(label) || 0) + 1)
})
})
return Array.from(counts.entries()).sort((a, b) => b[1] - a[1])
}, [tasks])
const allLabels = useMemo(() => labelUsage.map(([label]) => label), [labelUsage])
// Sync from server on mount
useEffect(() => {
console.log('>>> PAGE: useEffect for syncFromServer running')
syncFromServer().then(() => {
console.log('>>> PAGE: syncFromServer completed')
})
}, [syncFromServer])
// Log when tasks change
useEffect(() => {
console.log('>>> PAGE: tasks changed, new count:', tasks.length)
}, [tasks])
const selectedTask = tasks.find((t) => t.id === selectedTaskId)
const editedTaskTags = editedTask ? getTags(editedTask) : []
useEffect(() => {
if (selectedTask) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setEditedTask({ ...selectedTask, tags: getTags(selectedTask) })
setEditedTaskLabelInput("")
}
}, [selectedTask])
// Get current active sprint (across all projects)
const now = new Date()
const currentSprint = sprints.find((s) =>
s.status === 'active' &&
new Date(s.startDate) <= now &&
new Date(s.endDate) >= now
)
// Filter tasks to only show current sprint tasks in Kanban (from ALL projects)
const sprintTasks = currentSprint
? tasks.filter((t) => t.sprintId === currentSprint.id)
: []
const activeKanbanTask = activeKanbanTaskId
? sprintTasks.find((task) => task.id === activeKanbanTaskId)
: null
const toLabel = (raw: string) => raw.trim().replace(/^#/, "")
const addUniqueLabel = (existing: string[], raw: string) => {
const nextLabel = toLabel(raw)
if (!nextLabel) return existing
const alreadyExists = existing.some((label) => label.toLowerCase() === nextLabel.toLowerCase())
return alreadyExists ? existing : [...existing, nextLabel]
}
const removeLabel = (existing: string[], labelToRemove: string) =>
existing.filter((label) => label.toLowerCase() !== labelToRemove.toLowerCase())
const getColumnKeyForStatus = (status: TaskStatus) =>
sprintColumns.find((column) => column.statuses.includes(status))?.key
const kanbanSensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
)
const handleKanbanDragStart = (event: DragStartEvent) => {
setActiveKanbanTaskId(String(event.active.id))
}
const resolveKanbanColumnKey = (overId: string): string | null => {
if (overId.startsWith("kanban-col-")) {
return overId.replace("kanban-col-", "")
}
if (overId.startsWith("kanban-status-")) {
const status = overId.replace("kanban-status-", "") as TaskStatus
return getColumnKeyForStatus(status) || null
}
const overTask = sprintTasks.find((task) => task.id === overId)
if (!overTask) return null
return getColumnKeyForStatus(overTask.status) || null
}
const handleKanbanDragOver = (event: DragOverEvent) => {
if (!event.over) {
setDragOverKanbanColumnKey(null)
return
}
setDragOverKanbanColumnKey(resolveKanbanColumnKey(String(event.over.id)))
}
const handleKanbanDragEnd = (event: DragEndEvent) => {
const { active, over } = event
setActiveKanbanTaskId(null)
setDragOverKanbanColumnKey(null)
if (!over) return
const taskId = String(active.id)
const draggedTask = sprintTasks.find((task) => task.id === taskId)
if (!draggedTask) return
const destination = String(over.id)
const overTask = sprintTasks.find((task) => task.id === destination)
if (overTask) {
if (overTask.status !== draggedTask.status) {
updateTask(taskId, { status: overTask.status })
}
return
}
if (destination.startsWith("kanban-status-")) {
const exactStatus = destination.replace("kanban-status-", "") as TaskStatus
if (exactStatus !== draggedTask.status) {
updateTask(taskId, { status: exactStatus })
}
return
}
if (!destination.startsWith("kanban-col-")) return
const destinationColumnKey = destination.replace("kanban-col-", "")
const sourceColumnKey = getColumnKeyForStatus(draggedTask.status)
if (sourceColumnKey === destinationColumnKey) return
const newStatus = sprintColumnDropStatus[destinationColumnKey]
if (!newStatus) return
updateTask(taskId, { status: newStatus })
}
const handleKanbanDragCancel = () => {
setActiveKanbanTaskId(null)
setDragOverKanbanColumnKey(null)
}
const handleAddTask = () => {
if (newTask.title?.trim()) {
// If a specific sprint is selected, use that sprint's project
const selectedSprint = newTask.sprintId ? sprints.find(s => s.id === newTask.sprintId) : null
const targetProjectId = selectedSprint?.projectId || selectedProjectId || projects[0]?.id || '2'
const taskToCreate: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'comments'> = {
title: newTask.title.trim(),
description: newTask.description?.trim() || undefined,
type: (newTask.type || "task") as TaskType,
priority: (newTask.priority || "medium") as Priority,
status: (newTask.status || "open") as TaskStatus,
tags: newTask.tags || [],
projectId: targetProjectId,
sprintId: newTask.sprintId || currentSprint?.id,
}
addTask(taskToCreate)
setNewTask({ title: "", description: "", type: "task", priority: "medium", status: "open", tags: [], sprintId: undefined })
setNewTaskLabelInput("")
setNewTaskOpen(false)
}
}
const handleAddComment = () => {
if (newComment.trim() && selectedTaskId) {
addComment(selectedTaskId, newComment.trim(), "user")
setNewComment("")
}
}
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
{/* Header */}
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur">
<div className="max-w-[1800px] mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl md:text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
OpenClaw Task Hub
</h1>
<p className="text-xs md:text-sm text-slate-400 mt-1">
Track ideas, tasks, bugs, and plans with threaded notes
</p>
</div>
<div className="flex items-center gap-3">
{isLoading && (
<span className="flex items-center gap-1 text-xs text-blue-400">
<svg className="animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Syncing...
</span>
)}
<span className="hidden md:inline text-sm text-slate-400">
{tasks.length} tasks · {allLabels.length} labels
</span>
</div>
</div>
</div>
</header>
<div className="max-w-[1800px] mx-auto px-4 py-4 md:py-6">
{/* Main Content */}
<main className="min-w-0">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 md:mb-6">
<div>
<h2 className="text-lg md:text-xl font-semibold text-white">
{currentSprint ? currentSprint.name : "Work Board"}
</h2>
<p className="text-sm text-slate-400">
{sprintTasks.length} tasks · {sprintTasks.filter((t) => t.status === "done").length} done
</p>
</div>
<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('backlog')}
className={`flex items-center gap-1 px-3 py-1.5 rounded text-sm font-medium transition-colors ${
viewMode === 'backlog'
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:text-white'
}`}
>
<ListTodo className="w-4 h-4" />
Backlog
</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>
{/* View Content */}
{viewMode === 'backlog' ? (
<BacklogView />
) : (
<>
{/* Current Sprint Header */}
<div className="mb-4 p-3 bg-slate-800/50 border border-slate-700 rounded-lg">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-white">{currentSprint?.name || "No Active Sprint"}</h3>
<p className="text-sm text-slate-400">
{currentSprint
? `${new Date(currentSprint.startDate).toLocaleDateString()} - ${new Date(currentSprint.endDate).toLocaleDateString()}`
: "Create or activate a sprint to group work"}
</p>
</div>
{currentSprint && <Badge variant="default">Active</Badge>}
</div>
</div>
{/* Kanban Columns */}
<DndContext
sensors={kanbanSensors}
collisionDetection={closestCorners}
onDragStart={handleKanbanDragStart}
onDragOver={handleKanbanDragOver}
onDragEnd={handleKanbanDragEnd}
onDragCancel={handleKanbanDragCancel}
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{sprintColumns.map((column) => {
const columnTasks = sprintTasks.filter((t) =>
column.statuses.includes(t.status)
)
return (
<KanbanDropColumn
key={column.key}
id={`kanban-col-${column.key}`}
label={column.label}
count={columnTasks.length}
statusTargets={column.statuses.map((status) => ({
status,
count: sprintTasks.filter((task) => task.status === status).length,
}))}
isDragging={!!activeKanbanTaskId}
isActiveDropColumn={dragOverKanbanColumnKey === column.key}
>
{columnTasks.map((task) => (
<KanbanTaskCard
key={task.id}
task={task}
taskTags={getTags(task)}
onOpen={() => selectTask(task.id)}
onDelete={() => deleteTask(task.id)}
/>
))}
</KanbanDropColumn>
)
})}
</div>
<DragOverlay>
{activeKanbanTask ? (
<Card className="bg-slate-900 border-slate-700 shadow-2xl rotate-1">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<GripVertical className="w-3.5 h-3.5 text-slate-400" />
<span className="text-sm font-medium text-white">{activeKanbanTask.title}</span>
</div>
</CardContent>
</Card>
) : null}
</DragOverlay>
</DndContext>
</>
)}
</main>
</div>
{/* New Task Dialog */}
<Dialog open={newTaskOpen} onOpenChange={setNewTaskOpen}>
<DialogContent className="bg-slate-900 border-slate-800 text-white w-[95vw] max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>New Task</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label>Title</Label>
<input
type="text"
value={newTask.title}
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
className="w-full mt-1.5 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
placeholder="What needs to be done?"
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
className="mt-1.5 bg-slate-800 border-slate-700 text-white placeholder-slate-500"
placeholder="Add more details..."
rows={3}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label>Type</Label>
<select
value={newTask.type}
onChange={(e) => setNewTask({ ...newTask, type: e.target.value as TaskType })}
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"
>
{Object.entries(typeLabels).map(([type, label]) => (
<option key={type} value={type}>{label}</option>
))}
</select>
</div>
<div>
<Label>Priority</Label>
<select
value={newTask.priority}
onChange={(e) => setNewTask({ ...newTask, priority: e.target.value as Priority })}
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="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label>Status</Label>
<select
value={newTask.status}
onChange={(e) => setNewTask({ ...newTask, status: e.target.value as TaskStatus })}
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"
>
{allStatuses.map((status) => (
<option key={status} value={status}>{status.replace("-", " ").toUpperCase()}</option>
))}
</select>
</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="">Auto (Current Sprint)</option>
{sprints.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()).map((sprint) => (
<option key={sprint.id} value={sprint.id}>
{sprint.name} ({new Date(sprint.startDate).toLocaleDateString()})
</option>
))}
</select>
</div>
</div>
<div>
<Label>Labels</Label>
<div className="mt-1.5 space-y-2">
{newTask.tags && newTask.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{newTask.tags.map((label) => (
<Badge
key={label}
variant="secondary"
className="bg-slate-800 text-slate-300 gap-1"
>
{label}
<button
type="button"
onClick={() =>
setNewTask((prev) => ({ ...prev, tags: removeLabel(prev.tags || [], label) }))
}
className="text-slate-500 hover:text-slate-200"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
<div className="flex gap-2">
<input
type="text"
list="new-task-label-suggestions"
value={newTaskLabelInput}
onChange={(e) => setNewTaskLabelInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault()
setNewTask((prev) => ({
...prev,
tags: addUniqueLabel(prev.tags || [], newTaskLabelInput),
}))
setNewTaskLabelInput("")
}
}}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
placeholder="Type a label and press Enter"
/>
<Button
type="button"
variant="outline"
onClick={() => {
setNewTask((prev) => ({ ...prev, tags: addUniqueLabel(prev.tags || [], newTaskLabelInput) }))
setNewTaskLabelInput("")
}}
>
Add
</Button>
</div>
<datalist id="new-task-label-suggestions">
{allLabels.map((label) => (
<option key={label} value={label} />
))}
</datalist>
{allLabels.filter((label) => !(newTask.tags || []).some((tag) => tag.toLowerCase() === label.toLowerCase())).slice(0, 8).length > 0 && (
<div className="flex flex-wrap gap-2">
{allLabels
.filter((label) => !(newTask.tags || []).some((tag) => tag.toLowerCase() === label.toLowerCase()))
.slice(0, 8)
.map((label) => (
<button
key={label}
type="button"
onClick={() => setNewTask((prev) => ({ ...prev, tags: addUniqueLabel(prev.tags || [], label) }))}
className="text-xs px-2 py-1 rounded-md border border-slate-700 text-slate-300 hover:border-slate-500"
>
+ {label}
</button>
))}
</div>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setNewTaskOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddTask}>Create Task</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Task Detail Dialog with Comments */}
<Dialog open={!!selectedTaskId} onOpenChange={() => {
selectTask(null)
setEditedTask(null)
}}>
<DialogContent className="bg-slate-900 border-slate-800 text-white w-[95vw] max-w-2xl max-h-[90vh] overflow-y-auto p-4 md:p-6">
{selectedTask && editedTask && (
<>
<DialogHeader>
<div className="flex items-center gap-2 mb-2">
<Badge className={`${typeColors[editedTask.type]} text-white border-0`}>
{typeLabels[editedTask.type]}
</Badge>
<Badge variant="outline" className={priorityColors[editedTask.priority]}>
{editedTask.priority}
</Badge>
</div>
<input
type="text"
value={editedTask.title}
onChange={(e) => setEditedTask({ ...editedTask, title: e.target.value })}
className="w-full text-xl font-semibold bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white"
/>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Description */}
<div>
<Label className="text-slate-400">Description</Label>
<textarea
value={editedTask.description || ""}
onChange={(e) => setEditedTask({ ...editedTask, description: e.target.value })}
rows={3}
className="w-full mt-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
/>
</div>
{/* Priority */}
<div>
<Label className="text-slate-400">Priority</Label>
<div className="flex gap-2 mt-2">
{(["low", "medium", "high", "urgent"] as Priority[]).map((priority) => (
<button
key={priority}
onClick={() => setEditedTask({ ...editedTask, priority })}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors capitalize ${
editedTask.priority === priority
? priority === "urgent"
? "bg-red-600 text-white"
: priority === "high"
? "bg-orange-600 text-white"
: priority === "medium"
? "bg-blue-600 text-white"
: "bg-slate-600 text-white"
: "bg-slate-800 text-slate-400 hover:bg-slate-700"
}`}
>
{priority}
</button>
))}
</div>
</div>
{/* Status */}
<div>
<Label className="text-slate-400">Status</Label>
<select
value={editedTask.status}
onChange={(e) => setEditedTask({ ...editedTask, status: e.target.value as TaskStatus })}
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"
>
{allStatuses.map((status) => (
<option key={status} value={status}>{status.replace("-", " ").toUpperCase()}</option>
))}
</select>
</div>
{/* Sprint */}
<div>
<Label className="text-slate-400">Sprint</Label>
<select
value={editedTask.sprintId || ""}
onChange={(e) => setEditedTask({ ...editedTask, 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.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()).map((sprint) => (
<option key={sprint.id} value={sprint.id}>
{sprint.name} ({new Date(sprint.startDate).toLocaleDateString()})
</option>
))}
</select>
</div>
{/* Labels */}
<div>
<Label className="text-slate-400">Labels</Label>
<div className="mt-2 space-y-2">
{editedTaskTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{editedTaskTags.map((label) => (
<Badge
key={label}
variant="secondary"
className="bg-slate-800 text-slate-300 gap-1"
>
{label}
<button
type="button"
onClick={() =>
setEditedTask({ ...editedTask, tags: removeLabel(editedTaskTags, label) })
}
className="text-slate-500 hover:text-slate-200"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
<div className="flex gap-2">
<input
type="text"
list="edit-task-label-suggestions"
value={editedTaskLabelInput}
onChange={(e) => setEditedTaskLabelInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault()
setEditedTask({ ...editedTask, tags: addUniqueLabel(editedTaskTags, editedTaskLabelInput) })
setEditedTaskLabelInput("")
}
}}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
placeholder="Type a label and press Enter"
/>
<Button
type="button"
variant="outline"
onClick={() => {
setEditedTask({ ...editedTask, tags: addUniqueLabel(editedTaskTags, editedTaskLabelInput) })
setEditedTaskLabelInput("")
}}
>
Add
</Button>
</div>
<datalist id="edit-task-label-suggestions">
{allLabels.map((label) => (
<option key={label} value={label} />
))}
</datalist>
{allLabels.filter((label) => !editedTaskTags.some((tag) => tag.toLowerCase() === label.toLowerCase())).slice(0, 8).length > 0 && (
<div className="flex flex-wrap gap-2">
{allLabels
.filter((label) => !editedTaskTags.some((tag) => tag.toLowerCase() === label.toLowerCase()))
.slice(0, 8)
.map((label) => (
<button
key={label}
type="button"
onClick={() => setEditedTask({ ...editedTask, tags: addUniqueLabel(editedTaskTags, label) })}
className="text-xs px-2 py-1 rounded-md border border-slate-700 text-slate-300 hover:border-slate-500"
>
+ {label}
</button>
))}
</div>
)}
</div>
</div>
{/* Comments Section */}
<div className="border-t border-slate-800 pt-6">
<h4 className="font-medium text-white mb-4 flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Notes & Comments ({editedTask.comments?.length || 0})
</h4>
{/* Comment List */}
<div className="space-y-4 mb-4">
{!editedTask.comments || editedTask.comments.length === 0 ? (
<p className="text-slate-500 text-sm">No notes yet. Add the first one!</p>
) : (
editedTask.comments.map((comment) => (
<div
key={comment.id}
className={`flex gap-3 p-3 rounded-lg ${
comment.author === "assistant" ? "bg-blue-900/20" : "bg-slate-800/50"
}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${
comment.author === "assistant"
? "bg-blue-600 text-white"
: "bg-slate-700 text-slate-300"
}`}
>
{comment.author === "assistant" ? "AI" : "You"}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-slate-300">
{comment.author === "assistant" ? "Assistant" : "You"}
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">
{new Date(comment.createdAt).toLocaleString()}
</span>
<button
onClick={() => deleteComment(selectedTask.id, comment.id)}
className="text-slate-600 hover:text-red-400"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
<p className="text-slate-300 text-sm whitespace-pre-wrap">{comment.text}</p>
</div>
</div>
))
)}
</div>
{/* Add Comment */}
<div className="flex gap-3">
<Textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Add a note or comment..."
className="flex-1 bg-slate-800 border-slate-700 text-white placeholder-slate-500"
rows={2}
onKeyDown={(e) => {
if (e.key === "Enter" && e.metaKey) {
handleAddComment()
}
}}
/>
<Button onClick={handleAddComment} className="self-end">
Add
</Button>
</div>
</div>
</div>
<DialogFooter className="border-t border-slate-800 pt-4 flex justify-between">
<Button
variant="ghost"
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => {
deleteTask(selectedTask.id)
selectTask(null)
setEditedTask(null)
}}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Task
</Button>
<div className="flex items-center gap-3">
<Button variant="ghost" onClick={() => {
selectTask(null)
setEditedTask(null)
}}>
Cancel
</Button>
<Button onClick={() => {
updateTask(editedTask.id, editedTask)
selectTask(null)
setEditedTask(null)
}}>
Save Changes
</Button>
</div>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</div>
)
}