Merge pull request #4 from serversdwn/claude/nested-todo-app-mvp-014vUjLpD8wNjkMhLMXS6Kd5
0.1.3 Tweaks.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { Routes, Route } from 'react-router-dom'
|
import { Routes, Route } from 'react-router-dom'
|
||||||
import ProjectList from './pages/ProjectList'
|
import ProjectList from './pages/ProjectList'
|
||||||
import ProjectView from './pages/ProjectView'
|
import ProjectView from './pages/ProjectView'
|
||||||
|
import SearchBar from './components/SearchBar'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -15,6 +16,7 @@ function App() {
|
|||||||
<span className="ml-2 text-xs text-gray-600">v0.1.3</span>
|
<span className="ml-2 text-xs text-gray-600">v0.1.3</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<SearchBar />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Plus, Edit2, Trash2, Check, X } from 'lucide-react'
|
import { Plus, Check, X, Flag, Clock } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
getProjectTasks,
|
getProjectTasks,
|
||||||
createTask,
|
createTask,
|
||||||
updateTask,
|
updateTask,
|
||||||
deleteTask
|
deleteTask
|
||||||
} from '../utils/api'
|
} from '../utils/api'
|
||||||
|
import { formatTimeWithTotal } from '../utils/format'
|
||||||
|
import TaskMenu from './TaskMenu'
|
||||||
|
import TaskForm from './TaskForm'
|
||||||
|
|
||||||
const STATUSES = [
|
const STATUSES = [
|
||||||
{ key: 'backlog', label: 'Backlog', color: 'border-gray-600' },
|
{ key: 'backlog', label: 'Backlog', color: 'border-gray-600' },
|
||||||
@@ -14,6 +17,16 @@ const STATUSES = [
|
|||||||
{ key: 'done', label: 'Done', color: 'border-green-500' }
|
{ key: 'done', label: 'Done', color: 'border-green-500' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const FLAG_COLORS = {
|
||||||
|
red: 'bg-red-500',
|
||||||
|
orange: 'bg-orange-500',
|
||||||
|
yellow: 'bg-yellow-500',
|
||||||
|
green: 'bg-green-500',
|
||||||
|
blue: 'bg-blue-500',
|
||||||
|
purple: 'bg-purple-500',
|
||||||
|
pink: 'bg-pink-500'
|
||||||
|
}
|
||||||
|
|
||||||
function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [editTitle, setEditTitle] = useState(task.title)
|
const [editTitle, setEditTitle] = useState(task.title)
|
||||||
@@ -78,26 +91,56 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
|||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-gray-200 text-sm">{task.title}</div>
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{/* Flag indicator */}
|
||||||
|
{task.flag_color && FLAG_COLORS[task.flag_color] && (
|
||||||
|
<Flag size={12} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" />
|
||||||
|
)}
|
||||||
|
<span className="text-gray-200">{task.title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Parent task context */}
|
||||||
{parentTask && (
|
{parentTask && (
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
↳ subtask of: <span className="text-cyber-orange">{parentTask.title}</span>
|
↳ subtask of: <span className="text-cyber-orange">{parentTask.title}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Metadata row */}
|
||||||
|
{(formatTimeWithTotal(task, allTasks) || (task.tags && task.tags.length > 0)) && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
{/* Time estimate */}
|
||||||
|
{formatTimeWithTotal(task, allTasks) && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Clock size={11} />
|
||||||
|
<span>{formatTimeWithTotal(task, allTasks)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{task.tags && task.tags.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{task.tags.map((tag, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="inline-flex items-center px-1.5 py-0.5 text-xs bg-cyber-orange/20 text-cyber-orange border border-cyber-orange/30 rounded"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<TaskMenu
|
||||||
onClick={() => setIsEditing(true)}
|
task={task}
|
||||||
className="text-gray-400 hover:text-gray-200"
|
onUpdate={onUpdate}
|
||||||
>
|
onDelete={handleDelete}
|
||||||
<Edit2 size={14} />
|
onEdit={() => setIsEditing(true)}
|
||||||
</button>
|
/>
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="text-gray-600 hover:text-red-400"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -108,20 +151,18 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
|||||||
|
|
||||||
function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, onDragOver }) {
|
function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, onDragOver }) {
|
||||||
const [showAddTask, setShowAddTask] = useState(false)
|
const [showAddTask, setShowAddTask] = useState(false)
|
||||||
const [newTaskTitle, setNewTaskTitle] = useState('')
|
|
||||||
|
|
||||||
const handleAddTask = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!newTaskTitle.trim()) return
|
|
||||||
|
|
||||||
|
const handleAddTask = async (taskData) => {
|
||||||
try {
|
try {
|
||||||
await createTask({
|
await createTask({
|
||||||
project_id: parseInt(projectId),
|
project_id: parseInt(projectId),
|
||||||
parent_task_id: null,
|
parent_task_id: null,
|
||||||
title: newTaskTitle,
|
title: taskData.title,
|
||||||
status: status.key
|
status: status.key,
|
||||||
|
tags: taskData.tags,
|
||||||
|
estimated_minutes: taskData.estimated_minutes,
|
||||||
|
flag_color: taskData.flag_color
|
||||||
})
|
})
|
||||||
setNewTaskTitle('')
|
|
||||||
setShowAddTask(false)
|
setShowAddTask(false)
|
||||||
onUpdate()
|
onUpdate()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -150,31 +191,11 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
|
|||||||
|
|
||||||
{showAddTask && (
|
{showAddTask && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<form onSubmit={handleAddTask}>
|
<TaskForm
|
||||||
<input
|
onSubmit={handleAddTask}
|
||||||
type="text"
|
onCancel={() => setShowAddTask(false)}
|
||||||
value={newTaskTitle}
|
submitLabel="Add Task"
|
||||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
/>
|
||||||
placeholder="Task title..."
|
|
||||||
className="w-full px-2 py-2 bg-cyber-darkest border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange mb-2"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="px-3 py-1 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright text-sm font-semibold"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAddTask(false)}
|
|
||||||
className="px-3 py-1 text-gray-400 hover:text-gray-200 text-sm"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
262
frontend/src/components/SearchBar.jsx
Normal file
262
frontend/src/components/SearchBar.jsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Search, X, Flag } from 'lucide-react'
|
||||||
|
import { searchTasks, getProjects } from '../utils/api'
|
||||||
|
import { formatTime } from '../utils/format'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
const FLAG_COLORS = {
|
||||||
|
red: 'text-red-500',
|
||||||
|
orange: 'text-orange-500',
|
||||||
|
yellow: 'text-yellow-500',
|
||||||
|
green: 'text-green-500',
|
||||||
|
blue: 'text-blue-500',
|
||||||
|
purple: 'text-purple-500',
|
||||||
|
pink: 'text-pink-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchBar() {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState([])
|
||||||
|
const [projects, setProjects] = useState([])
|
||||||
|
const [selectedProjects, setSelectedProjects] = useState([])
|
||||||
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
|
const [showResults, setShowResults] = useState(false)
|
||||||
|
const [showProjectFilter, setShowProjectFilter] = useState(false)
|
||||||
|
const searchRef = useRef(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProjects()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event) {
|
||||||
|
if (searchRef.current && !searchRef.current.contains(event.target)) {
|
||||||
|
setShowResults(false)
|
||||||
|
setShowProjectFilter(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadProjects = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getProjects()
|
||||||
|
setProjects(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load projects:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = async (searchQuery) => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setResults([])
|
||||||
|
setShowResults(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true)
|
||||||
|
try {
|
||||||
|
const projectIds = selectedProjects.length > 0 ? selectedProjects : null
|
||||||
|
const data = await searchTasks(searchQuery, projectIds)
|
||||||
|
setResults(data)
|
||||||
|
setShowResults(true)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Search failed:', err)
|
||||||
|
setResults([])
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQueryChange = (e) => {
|
||||||
|
const newQuery = e.target.value
|
||||||
|
setQuery(newQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced search effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setResults([])
|
||||||
|
setShowResults(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
handleSearch(query)
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId)
|
||||||
|
}, [query, selectedProjects])
|
||||||
|
|
||||||
|
const toggleProjectFilter = (projectId) => {
|
||||||
|
setSelectedProjects(prev => {
|
||||||
|
if (prev.includes(projectId)) {
|
||||||
|
return prev.filter(id => id !== projectId)
|
||||||
|
} else {
|
||||||
|
return [...prev, projectId]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTaskClick = (task) => {
|
||||||
|
navigate(`/project/${task.project_id}`)
|
||||||
|
setShowResults(false)
|
||||||
|
setQuery('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
setQuery('')
|
||||||
|
setResults([])
|
||||||
|
setShowResults(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={searchRef}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={handleQueryChange}
|
||||||
|
onFocus={() => query && setShowResults(true)}
|
||||||
|
placeholder="Search tasks..."
|
||||||
|
className="w-64 pl-9 pr-8 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange placeholder-gray-500"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
onClick={clearSearch}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Filter Button */}
|
||||||
|
{projects.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowProjectFilter(!showProjectFilter)}
|
||||||
|
className={`px-3 py-2 text-sm rounded border ${
|
||||||
|
selectedProjects.length > 0
|
||||||
|
? 'bg-cyber-orange/20 border-cyber-orange text-cyber-orange'
|
||||||
|
: 'bg-cyber-darker border-cyber-orange/30 text-gray-400'
|
||||||
|
} hover:border-cyber-orange transition-colors`}
|
||||||
|
>
|
||||||
|
{selectedProjects.length > 0 ? `${selectedProjects.length} Project(s)` : 'All Projects'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Filter Dropdown */}
|
||||||
|
{showProjectFilter && (
|
||||||
|
<div className="absolute top-12 right-0 z-50 w-64 bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-lg max-h-80 overflow-y-auto">
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="text-xs text-gray-400 px-2 py-1 mb-1">Filter by projects:</div>
|
||||||
|
{projects.map(project => (
|
||||||
|
<label
|
||||||
|
key={project.id}
|
||||||
|
className="flex items-center gap-2 px-2 py-2 hover:bg-cyber-darker rounded cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedProjects.includes(project.id)}
|
||||||
|
onChange={() => toggleProjectFilter(project.id)}
|
||||||
|
className="rounded border-cyber-orange/50 bg-cyber-darker text-cyber-orange focus:ring-cyber-orange focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-300">{project.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{selectedProjects.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProjects([])
|
||||||
|
if (query) handleSearch(query)
|
||||||
|
}}
|
||||||
|
className="w-full mt-2 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 border border-gray-600 rounded"
|
||||||
|
>
|
||||||
|
Clear Filter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search Results */}
|
||||||
|
{showResults && (
|
||||||
|
<div className="absolute top-12 left-0 z-50 w-96 bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-lg max-h-96 overflow-y-auto">
|
||||||
|
{isSearching ? (
|
||||||
|
<div className="p-4 text-center text-gray-400 text-sm">Searching...</div>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-gray-500 text-sm">No results found</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="text-xs text-gray-400 px-2 py-1 mb-1">
|
||||||
|
{results.length} result{results.length !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
{results.map(task => {
|
||||||
|
const project = projects.find(p => p.id === task.project_id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={task.id}
|
||||||
|
onClick={() => handleTaskClick(task)}
|
||||||
|
className="w-full text-left px-2 py-2 hover:bg-cyber-darker rounded transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{/* Flag */}
|
||||||
|
{task.flag_color && FLAG_COLORS[task.flag_color] && (
|
||||||
|
<Flag size={12} className={`mt-0.5 ${FLAG_COLORS[task.flag_color]}`} fill="currentColor" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Title */}
|
||||||
|
<div className="text-sm text-gray-200 truncate">{task.title}</div>
|
||||||
|
|
||||||
|
{/* Project name */}
|
||||||
|
{project && (
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">
|
||||||
|
in: <span className="text-cyber-orange">{project.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
{(task.estimated_minutes || (task.tags && task.tags.length > 0)) && (
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{task.estimated_minutes && (
|
||||||
|
<span className="text-xs text-gray-500">{formatTime(task.estimated_minutes)}</span>
|
||||||
|
)}
|
||||||
|
{task.tags && task.tags.length > 0 && (
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{task.tags.slice(0, 3).map((tag, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="inline-block px-1 py-0.5 text-xs bg-cyber-orange/20 text-cyber-orange border border-cyber-orange/30 rounded"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{task.tags.length > 3 && (
|
||||||
|
<span className="text-xs text-gray-500">+{task.tags.length - 3}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchBar
|
||||||
142
frontend/src/components/TaskForm.jsx
Normal file
142
frontend/src/components/TaskForm.jsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Flag } from 'lucide-react'
|
||||||
|
|
||||||
|
const FLAG_COLORS = [
|
||||||
|
{ name: null, label: 'None', color: 'bg-gray-700' },
|
||||||
|
{ name: 'red', label: 'Red', color: 'bg-red-500' },
|
||||||
|
{ name: 'orange', label: 'Orange', color: 'bg-orange-500' },
|
||||||
|
{ name: 'yellow', label: 'Yellow', color: 'bg-yellow-500' },
|
||||||
|
{ name: 'green', label: 'Green', color: 'bg-green-500' },
|
||||||
|
{ name: 'blue', label: 'Blue', color: 'bg-blue-500' },
|
||||||
|
{ name: 'purple', label: 'Purple', color: 'bg-purple-500' },
|
||||||
|
{ name: 'pink', label: 'Pink', color: 'bg-pink-500' }
|
||||||
|
]
|
||||||
|
|
||||||
|
function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [tags, setTags] = useState('')
|
||||||
|
const [hours, setHours] = useState('')
|
||||||
|
const [minutes, setMinutes] = useState('')
|
||||||
|
const [flagColor, setFlagColor] = useState(null)
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!title.trim()) return
|
||||||
|
|
||||||
|
// Convert hours and minutes to total minutes
|
||||||
|
const totalMinutes = (parseInt(hours) || 0) * 60 + (parseInt(minutes) || 0)
|
||||||
|
|
||||||
|
// Parse tags
|
||||||
|
const tagList = tags
|
||||||
|
? tags.split(',').map(t => t.trim()).filter(t => t.length > 0)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const taskData = {
|
||||||
|
title: title.trim(),
|
||||||
|
tags: tagList && tagList.length > 0 ? tagList : null,
|
||||||
|
estimated_minutes: totalMinutes > 0 ? totalMinutes : null,
|
||||||
|
flag_color: flagColor
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(taskData)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-cyber-darkest border border-cyber-orange/30 rounded-lg p-4 space-y-3">
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Task Title *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Enter task title..."
|
||||||
|
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Tags (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)}
|
||||||
|
placeholder="coding, bug-fix, frontend"
|
||||||
|
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Estimate */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Time Estimate</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={hours}
|
||||||
|
onChange={(e) => setHours(e.target.value)}
|
||||||
|
placeholder="Hours"
|
||||||
|
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
value={minutes}
|
||||||
|
onChange={(e) => setMinutes(e.target.value)}
|
||||||
|
placeholder="Minutes"
|
||||||
|
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flag Color */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Flag Color</label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{FLAG_COLORS.map(({ name, label, color }) => (
|
||||||
|
<button
|
||||||
|
key={name || 'none'}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFlagColor(name)}
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-all ${
|
||||||
|
flagColor === name
|
||||||
|
? 'bg-cyber-orange/20 border-2 border-cyber-orange'
|
||||||
|
: 'border-2 border-transparent hover:border-cyber-orange/40'
|
||||||
|
}`}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<div className={`w-4 h-4 ${color} rounded`} />
|
||||||
|
{flagColor === name && '✓'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold text-sm transition-colors"
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-gray-400 hover:text-gray-200 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskForm
|
||||||
335
frontend/src/components/TaskMenu.jsx
Normal file
335
frontend/src/components/TaskMenu.jsx
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo } from 'lucide-react'
|
||||||
|
import { updateTask } from '../utils/api'
|
||||||
|
|
||||||
|
const FLAG_COLORS = [
|
||||||
|
{ name: 'red', color: 'bg-red-500' },
|
||||||
|
{ name: 'orange', color: 'bg-orange-500' },
|
||||||
|
{ name: 'yellow', color: 'bg-yellow-500' },
|
||||||
|
{ name: 'green', color: 'bg-green-500' },
|
||||||
|
{ name: 'blue', color: 'bg-blue-500' },
|
||||||
|
{ name: 'purple', color: 'bg-purple-500' },
|
||||||
|
{ name: 'pink', color: 'bg-pink-500' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUSES = [
|
||||||
|
{ key: 'backlog', label: 'Backlog', color: 'text-gray-400' },
|
||||||
|
{ key: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
|
||||||
|
{ key: 'blocked', label: 'Blocked', color: 'text-red-400' },
|
||||||
|
{ key: 'done', label: 'Done', color: 'text-green-400' }
|
||||||
|
]
|
||||||
|
|
||||||
|
function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [showTimeEdit, setShowTimeEdit] = useState(false)
|
||||||
|
const [showTagsEdit, setShowTagsEdit] = useState(false)
|
||||||
|
const [showFlagEdit, setShowFlagEdit] = useState(false)
|
||||||
|
const [showStatusEdit, setShowStatusEdit] = useState(false)
|
||||||
|
|
||||||
|
// Calculate hours and minutes from task.estimated_minutes
|
||||||
|
const initialHours = task.estimated_minutes ? Math.floor(task.estimated_minutes / 60) : ''
|
||||||
|
const initialMinutes = task.estimated_minutes ? task.estimated_minutes % 60 : ''
|
||||||
|
|
||||||
|
const [editHours, setEditHours] = useState(initialHours)
|
||||||
|
const [editMinutes, setEditMinutes] = useState(initialMinutes)
|
||||||
|
const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '')
|
||||||
|
const menuRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event) {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
setShowTimeEdit(false)
|
||||||
|
setShowTagsEdit(false)
|
||||||
|
setShowFlagEdit(false)
|
||||||
|
setShowStatusEdit(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleUpdateTime = async () => {
|
||||||
|
try {
|
||||||
|
const totalMinutes = (parseInt(editHours) || 0) * 60 + (parseInt(editMinutes) || 0)
|
||||||
|
const minutes = totalMinutes > 0 ? totalMinutes : null
|
||||||
|
await updateTask(task.id, { estimated_minutes: minutes })
|
||||||
|
setShowTimeEdit(false)
|
||||||
|
setIsOpen(false)
|
||||||
|
onUpdate()
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateTags = async () => {
|
||||||
|
try {
|
||||||
|
const tags = editTags
|
||||||
|
? editTags.split(',').map(t => t.trim()).filter(t => t.length > 0)
|
||||||
|
: null
|
||||||
|
await updateTask(task.id, { tags })
|
||||||
|
setShowTagsEdit(false)
|
||||||
|
setIsOpen(false)
|
||||||
|
onUpdate()
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateFlag = async (color) => {
|
||||||
|
try {
|
||||||
|
await updateTask(task.id, { flag_color: color })
|
||||||
|
setShowFlagEdit(false)
|
||||||
|
setIsOpen(false)
|
||||||
|
onUpdate()
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearFlag = async () => {
|
||||||
|
try {
|
||||||
|
await updateTask(task.id, { flag_color: null })
|
||||||
|
setShowFlagEdit(false)
|
||||||
|
setIsOpen(false)
|
||||||
|
onUpdate()
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateStatus = async (newStatus) => {
|
||||||
|
try {
|
||||||
|
await updateTask(task.id, { status: newStatus })
|
||||||
|
setShowStatusEdit(false)
|
||||||
|
setIsOpen(false)
|
||||||
|
onUpdate()
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsOpen(!isOpen)
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-gray-200 p-1"
|
||||||
|
title="More options"
|
||||||
|
>
|
||||||
|
<MoreVertical size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 top-8 z-50 w-64 bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-lg overflow-hidden">
|
||||||
|
{/* Time Edit */}
|
||||||
|
{showTimeEdit ? (
|
||||||
|
<div className="p-3 border-b border-cyber-orange/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Clock size={14} className="text-cyber-orange" />
|
||||||
|
<span className="text-sm text-gray-300">Time Estimate</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={editHours}
|
||||||
|
onChange={(e) => setEditHours(e.target.value)}
|
||||||
|
placeholder="Hours"
|
||||||
|
className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||||
|
autoFocus
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
value={editMinutes}
|
||||||
|
onChange={(e) => setEditMinutes(e.target.value)}
|
||||||
|
placeholder="Minutes"
|
||||||
|
className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleUpdateTime}
|
||||||
|
className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTimeEdit(false)}
|
||||||
|
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowTimeEdit(true)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
|
>
|
||||||
|
<Clock size={14} />
|
||||||
|
<span>Set Time Estimate</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags Edit */}
|
||||||
|
{showTagsEdit ? (
|
||||||
|
<div className="p-3 border-b border-cyber-orange/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Tag size={14} className="text-cyber-orange" />
|
||||||
|
<span className="text-sm text-gray-300">Tags (comma-separated)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editTags}
|
||||||
|
onChange={(e) => setEditTags(e.target.value)}
|
||||||
|
placeholder="coding, bug-fix"
|
||||||
|
className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||||
|
autoFocus
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleUpdateTags}
|
||||||
|
className="text-green-400 hover:text-green-300"
|
||||||
|
>
|
||||||
|
<Check size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTagsEdit(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowTagsEdit(true)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
|
>
|
||||||
|
<Tag size={14} />
|
||||||
|
<span>Edit Tags</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Flag Color Edit */}
|
||||||
|
{showFlagEdit ? (
|
||||||
|
<div className="p-3 border-b border-cyber-orange/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Flag size={14} className="text-cyber-orange" />
|
||||||
|
<span className="text-sm text-gray-300">Flag Color</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{FLAG_COLORS.map(({ name, color }) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onClick={() => handleUpdateFlag(name)}
|
||||||
|
className={`w-6 h-6 ${color} rounded hover:ring-2 hover:ring-cyber-orange transition-all`}
|
||||||
|
title={name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClearFlag}
|
||||||
|
className="w-full mt-2 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 border border-gray-600 rounded"
|
||||||
|
>
|
||||||
|
Clear Flag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowFlagEdit(true)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
|
>
|
||||||
|
<Flag size={14} />
|
||||||
|
<span>Set Flag Color</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Change */}
|
||||||
|
{showStatusEdit ? (
|
||||||
|
<div className="p-3 border-b border-cyber-orange/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<ListTodo size={14} className="text-cyber-orange" />
|
||||||
|
<span className="text-sm text-gray-300">Change Status</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{STATUSES.map(({ key, label, color }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => handleUpdateStatus(key)}
|
||||||
|
className={`w-full text-left px-2 py-1.5 rounded text-sm ${
|
||||||
|
task.status === key
|
||||||
|
? 'bg-cyber-orange/20 border border-cyber-orange/40'
|
||||||
|
: 'hover:bg-cyber-darker border border-transparent'
|
||||||
|
} ${color} transition-all`}
|
||||||
|
>
|
||||||
|
{label} {task.status === key && '✓'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowStatusEdit(true)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
|
>
|
||||||
|
<ListTodo size={14} />
|
||||||
|
<span>Change Status</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Title */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onEdit()
|
||||||
|
setIsOpen(false)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
|
>
|
||||||
|
<Edit2 size={14} />
|
||||||
|
<span>Edit Title</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDelete()
|
||||||
|
setIsOpen(false)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-red-400 hover:text-red-300 text-sm border-t border-cyber-orange/20"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
<span>Delete Task</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskMenu
|
||||||
@@ -3,10 +3,10 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Plus,
|
Plus,
|
||||||
Edit2,
|
|
||||||
Trash2,
|
|
||||||
Check,
|
Check,
|
||||||
X
|
X,
|
||||||
|
Flag,
|
||||||
|
Clock
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
getProjectTaskTree,
|
getProjectTaskTree,
|
||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
updateTask,
|
updateTask,
|
||||||
deleteTask
|
deleteTask
|
||||||
} from '../utils/api'
|
} from '../utils/api'
|
||||||
|
import { formatTimeWithTotal } from '../utils/format'
|
||||||
|
import TaskMenu from './TaskMenu'
|
||||||
|
import TaskForm from './TaskForm'
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
const STATUS_COLORS = {
|
||||||
backlog: 'text-gray-400',
|
backlog: 'text-gray-400',
|
||||||
@@ -29,13 +32,22 @@ const STATUS_LABELS = {
|
|||||||
done: 'Done'
|
done: 'Done'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FLAG_COLORS = {
|
||||||
|
red: 'bg-red-500',
|
||||||
|
orange: 'bg-orange-500',
|
||||||
|
yellow: 'bg-yellow-500',
|
||||||
|
green: 'bg-green-500',
|
||||||
|
blue: 'bg-blue-500',
|
||||||
|
purple: 'bg-purple-500',
|
||||||
|
pink: 'bg-pink-500'
|
||||||
|
}
|
||||||
|
|
||||||
function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
||||||
const [isExpanded, setIsExpanded] = useState(true)
|
const [isExpanded, setIsExpanded] = useState(true)
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [editTitle, setEditTitle] = useState(task.title)
|
const [editTitle, setEditTitle] = useState(task.title)
|
||||||
const [editStatus, setEditStatus] = useState(task.status)
|
const [editStatus, setEditStatus] = useState(task.status)
|
||||||
const [showAddSubtask, setShowAddSubtask] = useState(false)
|
const [showAddSubtask, setShowAddSubtask] = useState(false)
|
||||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('')
|
|
||||||
|
|
||||||
const hasSubtasks = task.subtasks && task.subtasks.length > 0
|
const hasSubtasks = task.subtasks && task.subtasks.length > 0
|
||||||
|
|
||||||
@@ -62,18 +74,17 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddSubtask = async (e) => {
|
const handleAddSubtask = async (taskData) => {
|
||||||
e.preventDefault()
|
|
||||||
if (!newSubtaskTitle.trim()) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createTask({
|
await createTask({
|
||||||
project_id: parseInt(projectId),
|
project_id: parseInt(projectId),
|
||||||
parent_task_id: task.id,
|
parent_task_id: task.id,
|
||||||
title: newSubtaskTitle,
|
title: taskData.title,
|
||||||
status: 'backlog'
|
status: 'backlog',
|
||||||
|
tags: taskData.tags,
|
||||||
|
estimated_minutes: taskData.estimated_minutes,
|
||||||
|
flag_color: taskData.flag_color
|
||||||
})
|
})
|
||||||
setNewSubtaskTitle('')
|
|
||||||
setShowAddSubtask(false)
|
setShowAddSubtask(false)
|
||||||
setIsExpanded(true)
|
setIsExpanded(true)
|
||||||
onUpdate()
|
onUpdate()
|
||||||
@@ -139,10 +150,43 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="text-gray-200">{task.title}</span>
|
<div className="flex items-center gap-2">
|
||||||
<span className={`ml-3 text-xs ${STATUS_COLORS[task.status]}`}>
|
{/* Flag indicator */}
|
||||||
{STATUS_LABELS[task.status]}
|
{task.flag_color && FLAG_COLORS[task.flag_color] && (
|
||||||
</span>
|
<Flag size={14} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" />
|
||||||
|
)}
|
||||||
|
<span className="text-gray-200">{task.title}</span>
|
||||||
|
<span className={`ml-2 text-xs ${STATUS_COLORS[task.status]}`}>
|
||||||
|
{STATUS_LABELS[task.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata row */}
|
||||||
|
{(formatTimeWithTotal(task) || (task.tags && task.tags.length > 0)) && (
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
{/* Time estimate */}
|
||||||
|
{formatTimeWithTotal(task) && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Clock size={12} />
|
||||||
|
<span>{formatTimeWithTotal(task)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{task.tags && task.tags.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{task.tags.map((tag, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-cyber-orange/20 text-cyber-orange border border-cyber-orange/30 rounded"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -154,20 +198,12 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<TaskMenu
|
||||||
onClick={() => setIsEditing(true)}
|
task={task}
|
||||||
className="text-gray-400 hover:text-gray-200"
|
onUpdate={onUpdate}
|
||||||
title="Edit"
|
onDelete={handleDelete}
|
||||||
>
|
onEdit={() => setIsEditing(true)}
|
||||||
<Edit2 size={16} />
|
/>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="text-gray-600 hover:text-red-400"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -176,29 +212,11 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
{/* Add Subtask Form */}
|
{/* Add Subtask Form */}
|
||||||
{showAddSubtask && (
|
{showAddSubtask && (
|
||||||
<div style={{ marginLeft: `${level * 1.5}rem` }} className="mt-2">
|
<div style={{ marginLeft: `${level * 1.5}rem` }} className="mt-2">
|
||||||
<form onSubmit={handleAddSubtask} className="flex gap-2">
|
<TaskForm
|
||||||
<input
|
onSubmit={handleAddSubtask}
|
||||||
type="text"
|
onCancel={() => setShowAddSubtask(false)}
|
||||||
value={newSubtaskTitle}
|
submitLabel="Add Subtask"
|
||||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
/>
|
||||||
placeholder="New subtask title..."
|
|
||||||
className="flex-1 px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="px-3 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright text-sm font-semibold"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAddSubtask(false)}
|
|
||||||
className="px-3 py-2 text-gray-400 hover:text-gray-200 text-sm"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -225,7 +243,6 @@ function TreeView({ projectId }) {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [showAddRoot, setShowAddRoot] = useState(false)
|
const [showAddRoot, setShowAddRoot] = useState(false)
|
||||||
const [newTaskTitle, setNewTaskTitle] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTasks()
|
loadTasks()
|
||||||
@@ -243,18 +260,17 @@ function TreeView({ projectId }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddRootTask = async (e) => {
|
const handleAddRootTask = async (taskData) => {
|
||||||
e.preventDefault()
|
|
||||||
if (!newTaskTitle.trim()) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createTask({
|
await createTask({
|
||||||
project_id: parseInt(projectId),
|
project_id: parseInt(projectId),
|
||||||
parent_task_id: null,
|
parent_task_id: null,
|
||||||
title: newTaskTitle,
|
title: taskData.title,
|
||||||
status: 'backlog'
|
status: 'backlog',
|
||||||
|
tags: taskData.tags,
|
||||||
|
estimated_minutes: taskData.estimated_minutes,
|
||||||
|
flag_color: taskData.flag_color
|
||||||
})
|
})
|
||||||
setNewTaskTitle('')
|
|
||||||
setShowAddRoot(false)
|
setShowAddRoot(false)
|
||||||
loadTasks()
|
loadTasks()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -285,29 +301,11 @@ function TreeView({ projectId }) {
|
|||||||
|
|
||||||
{showAddRoot && (
|
{showAddRoot && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<form onSubmit={handleAddRootTask} className="flex gap-2">
|
<TaskForm
|
||||||
<input
|
onSubmit={handleAddRootTask}
|
||||||
type="text"
|
onCancel={() => setShowAddRoot(false)}
|
||||||
value={newTaskTitle}
|
submitLabel="Add Task"
|
||||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
/>
|
||||||
placeholder="New task title..."
|
|
||||||
className="flex-1 px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 focus:outline-none focus:border-cyber-orange"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAddRoot(false)}
|
|
||||||
className="px-4 py-2 text-gray-400 hover:text-gray-200"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Format minutes into display string
|
// Format minutes into display string (e.g., "1h 30m" or "45m")
|
||||||
export function formatTime(minutes) {
|
export function formatTime(minutes) {
|
||||||
if (!minutes || minutes === 0) return null;
|
if (!minutes || minutes === 0) return null;
|
||||||
|
|
||||||
@@ -6,8 +6,14 @@ export function formatTime(minutes) {
|
|||||||
return `${minutes}m`;
|
return `${minutes}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hours = minutes / 60;
|
const hours = Math.floor(minutes / 60);
|
||||||
return `${hours.toFixed(1)}h`;
|
const mins = minutes % 60;
|
||||||
|
|
||||||
|
if (mins === 0) {
|
||||||
|
return `${hours}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format tags as comma-separated string
|
// Format tags as comma-separated string
|
||||||
@@ -15,3 +21,66 @@ export function formatTags(tags) {
|
|||||||
if (!tags || tags.length === 0) return null;
|
if (!tags || tags.length === 0) return null;
|
||||||
return tags.join(', ');
|
return tags.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate sum of all LEAF descendant estimates (hierarchical structure)
|
||||||
|
// Excludes tasks marked as "done"
|
||||||
|
export function calculateLeafTime(task) {
|
||||||
|
// If no subtasks, this is a leaf - return its own estimate if not done
|
||||||
|
if (!task.subtasks || task.subtasks.length === 0) {
|
||||||
|
return (task.status !== 'done' && task.estimated_minutes) ? task.estimated_minutes : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has subtasks, so sum up all leaf descendants (excluding done tasks)
|
||||||
|
let total = 0;
|
||||||
|
for (const subtask of task.subtasks) {
|
||||||
|
total += calculateLeafTime(subtask);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate sum of all LEAF descendant estimates (flat task list)
|
||||||
|
// Excludes tasks marked as "done"
|
||||||
|
export function calculateLeafTimeFlat(task, allTasks) {
|
||||||
|
// Find direct children
|
||||||
|
const children = allTasks.filter(t => t.parent_task_id === task.id);
|
||||||
|
|
||||||
|
// If no children, this is a leaf - return its own estimate if not done
|
||||||
|
if (children.length === 0) {
|
||||||
|
return (task.status !== 'done' && task.estimated_minutes) ? task.estimated_minutes : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has children, so sum up all leaf descendants (excluding done tasks)
|
||||||
|
let total = 0;
|
||||||
|
for (const child of children) {
|
||||||
|
total += calculateLeafTimeFlat(child, allTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format time display based on leaf calculation logic
|
||||||
|
export function formatTimeWithTotal(task, allTasks = null) {
|
||||||
|
// Check if task has subtasks
|
||||||
|
const hasSubtasks = allTasks
|
||||||
|
? allTasks.some(t => t.parent_task_id === task.id)
|
||||||
|
: (task.subtasks && task.subtasks.length > 0);
|
||||||
|
|
||||||
|
// Leaf task: use own estimate
|
||||||
|
if (!hasSubtasks) {
|
||||||
|
return formatTime(task.estimated_minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent task: calculate sum of leaf descendants
|
||||||
|
const leafTotal = allTasks
|
||||||
|
? calculateLeafTimeFlat(task, allTasks)
|
||||||
|
: calculateLeafTime(task);
|
||||||
|
|
||||||
|
// If no leaf estimates exist, fall back to own estimate
|
||||||
|
if (leafTotal === 0) {
|
||||||
|
return formatTime(task.estimated_minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show leaf total
|
||||||
|
return formatTime(leafTotal);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user