feat: add task blocker dependency graph + actionable now view
- New task_blockers join table (many-to-many, cross-project)
- Cycle detection prevents circular dependencies
- GET /api/tasks/{id}/blockers and /blocking endpoints
- POST/DELETE /api/tasks/{id}/blockers/{blocker_id}
- GET /api/actionable — all non-done tasks with no incomplete blockers
- BlockerPanel.jsx — search & manage blockers per task (via task menu)
- ActionableView.jsx — "what can I do right now?" dashboard grouped by project
- "Now" button in nav header routes to actionable view
- migrate_add_blockers.py migration script for existing databases
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,34 +1,56 @@
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import ProjectList from './pages/ProjectList'
|
||||
import ProjectView from './pages/ProjectView'
|
||||
import SearchBar from './components/SearchBar'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-cyber-dark">
|
||||
<header className="border-b border-cyber-orange/30 bg-cyber-darkest">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-cyber-orange">
|
||||
BIT
|
||||
<span className="ml-3 text-sm text-gray-500">Break It Down</span>
|
||||
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
|
||||
</h1>
|
||||
</div>
|
||||
<SearchBar />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<ProjectList />} />
|
||||
<Route path="/project/:projectId" element={<ProjectView />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Zap } from 'lucide-react'
|
||||
import ProjectList from './pages/ProjectList'
|
||||
import ProjectView from './pages/ProjectView'
|
||||
import ActionableView from './pages/ActionableView'
|
||||
import SearchBar from './components/SearchBar'
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const isActionable = location.pathname === '/actionable'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-cyber-dark">
|
||||
<header className="border-b border-cyber-orange/30 bg-cyber-darkest">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1
|
||||
className="text-2xl font-bold text-cyber-orange cursor-pointer"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
BIT
|
||||
<span className="ml-3 text-sm text-gray-500">Break It Down</span>
|
||||
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => navigate(isActionable ? '/' : '/actionable')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-all ${
|
||||
isActionable
|
||||
? 'bg-cyber-orange text-cyber-darkest'
|
||||
: 'border border-cyber-orange/40 text-cyber-orange hover:bg-cyber-orange/10'
|
||||
}`}
|
||||
title="What can I do right now?"
|
||||
>
|
||||
<Zap size={14} />
|
||||
Now
|
||||
</button>
|
||||
</div>
|
||||
<SearchBar />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<ProjectList />} />
|
||||
<Route path="/project/:projectId" element={<ProjectView />} />
|
||||
<Route path="/actionable" element={<ActionableView />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
201
frontend/src/components/BlockerPanel.jsx
Normal file
201
frontend/src/components/BlockerPanel.jsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X, Search, Lock, Unlock, AlertTriangle } from 'lucide-react'
|
||||
import { getTaskBlockers, addBlocker, removeBlocker, searchTasks } from '../utils/api'
|
||||
|
||||
function BlockerPanel({ task, onClose, onUpdate }) {
|
||||
const [blockers, setBlockers] = useState([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const searchTimeout = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadBlockers()
|
||||
}, [task.id])
|
||||
|
||||
const loadBlockers = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await getTaskBlockers(task.id)
|
||||
setBlockers(data)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (query) => {
|
||||
setSearchQuery(query)
|
||||
setError('')
|
||||
|
||||
if (searchTimeout.current) clearTimeout(searchTimeout.current)
|
||||
|
||||
if (!query.trim()) {
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
|
||||
searchTimeout.current = setTimeout(async () => {
|
||||
try {
|
||||
setSearching(true)
|
||||
const results = await searchTasks(query)
|
||||
// Filter out the current task and tasks already blocking this one
|
||||
const blockerIds = new Set(blockers.map(b => b.id))
|
||||
const filtered = results.filter(t => t.id !== task.id && !blockerIds.has(t.id))
|
||||
setSearchResults(filtered.slice(0, 8))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleAddBlocker = async (blocker) => {
|
||||
try {
|
||||
setError('')
|
||||
await addBlocker(task.id, blocker.id)
|
||||
setBlockers(prev => [...prev, blocker])
|
||||
setSearchResults(prev => prev.filter(t => t.id !== blocker.id))
|
||||
setSearchQuery('')
|
||||
setSearchResults([])
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveBlocker = async (blockerId) => {
|
||||
try {
|
||||
setError('')
|
||||
await removeBlocker(task.id, blockerId)
|
||||
setBlockers(prev => prev.filter(b => b.id !== blockerId))
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const incompleteBlockers = blockers.filter(b => b.status !== 'done')
|
||||
const isBlocked = incompleteBlockers.length > 0
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div
|
||||
className="bg-cyber-darkest border border-cyber-orange/50 rounded-lg w-full max-w-lg shadow-xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-cyber-orange/20">
|
||||
<div className="flex items-center gap-2">
|
||||
{isBlocked
|
||||
? <Lock size={16} className="text-red-400" />
|
||||
: <Unlock size={16} className="text-green-400" />
|
||||
}
|
||||
<span className="font-semibold text-gray-100 text-sm">Blockers</span>
|
||||
<span className="text-xs text-gray-500 ml-1 truncate max-w-[200px]" title={task.title}>
|
||||
— {task.title}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-200 transition-colors">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Status banner */}
|
||||
{isBlocked ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-red-900/20 border border-red-500/30 rounded text-red-300 text-sm">
|
||||
<AlertTriangle size={14} />
|
||||
<span>{incompleteBlockers.length} incomplete blocker{incompleteBlockers.length !== 1 ? 's' : ''} — this task is locked</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-green-900/20 border border-green-500/30 rounded text-green-300 text-sm">
|
||||
<Unlock size={14} />
|
||||
<span>No active blockers — this task is ready to work on</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current blockers list */}
|
||||
{loading ? (
|
||||
<div className="text-gray-500 text-sm text-center py-2">Loading...</div>
|
||||
) : blockers.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Blocked by</p>
|
||||
{blockers.map(b => (
|
||||
<div
|
||||
key={b.id}
|
||||
className="flex items-center justify-between px-3 py-2 bg-cyber-darker rounded border border-cyber-orange/10"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${b.status === 'done' ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||
<span className="text-sm text-gray-200 truncate">{b.title}</span>
|
||||
<span className="text-xs text-gray-500 flex-shrink-0">#{b.id}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveBlocker(b.id)}
|
||||
className="text-gray-600 hover:text-red-400 transition-colors flex-shrink-0 ml-2"
|
||||
title="Remove blocker"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 text-center py-1">No blockers added yet</p>
|
||||
)}
|
||||
|
||||
{/* Search to add blocker */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Add a blocker</p>
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
placeholder="Search tasks across all projects..."
|
||||
className="w-full pl-9 pr-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search results */}
|
||||
{(searchResults.length > 0 || searching) && (
|
||||
<div className="mt-1 border border-cyber-orange/20 rounded overflow-hidden">
|
||||
{searching && (
|
||||
<div className="px-3 py-2 text-xs text-gray-500">Searching...</div>
|
||||
)}
|
||||
{searchResults.map(result => (
|
||||
<button
|
||||
key={result.id}
|
||||
onClick={() => handleAddBlocker(result)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-left transition-colors border-b border-cyber-orange/10 last:border-0"
|
||||
>
|
||||
<span className="text-sm text-gray-200 truncate flex-1">{result.title}</span>
|
||||
<span className="text-xs text-gray-600 flex-shrink-0">project #{result.project_id}</span>
|
||||
</button>
|
||||
))}
|
||||
{!searching && searchResults.length === 0 && searchQuery && (
|
||||
<div className="px-3 py-2 text-xs text-gray-500">No matching tasks found</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-2 bg-red-900/30 border border-red-500/40 rounded text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BlockerPanel
|
||||
@@ -1,405 +1,429 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText } 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' }
|
||||
]
|
||||
|
||||
// Helper to format status label
|
||||
const formatStatusLabel = (status) => {
|
||||
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
// Helper to get status color
|
||||
const getStatusTextColor = (status) => {
|
||||
const lowerStatus = status.toLowerCase()
|
||||
if (lowerStatus === 'backlog') return 'text-gray-400'
|
||||
if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'text-blue-400'
|
||||
if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'text-yellow-400'
|
||||
if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'text-green-400'
|
||||
if (lowerStatus.includes('blocked')) return 'text-red-400'
|
||||
return 'text-purple-400' // default for custom statuses
|
||||
}
|
||||
|
||||
function TaskMenu({ task, onUpdate, onDelete, onEdit, projectStatuses }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showTimeEdit, setShowTimeEdit] = useState(false)
|
||||
const [showDescriptionEdit, setShowDescriptionEdit] = 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 [editDescription, setEditDescription] = useState(task.description || '')
|
||||
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)
|
||||
setShowDescriptionEdit(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 handleUpdateDescription = async () => {
|
||||
try {
|
||||
const description = editDescription.trim() || null
|
||||
await updateTask(task.id, { description })
|
||||
setShowDescriptionEdit(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>
|
||||
)}
|
||||
|
||||
{/* Description Edit */}
|
||||
{showDescriptionEdit ? (
|
||||
<div className="p-3 border-b border-cyber-orange/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText size={14} className="text-cyber-orange" />
|
||||
<span className="text-sm text-gray-300">Description</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Task description..."
|
||||
rows="4"
|
||||
className="w-full 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 resize-y"
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleUpdateDescription}
|
||||
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={() => setShowDescriptionEdit(false)}
|
||||
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowDescriptionEdit(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span>Edit Description</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">
|
||||
{(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => handleUpdateStatus(status)}
|
||||
className={`w-full text-left px-2 py-1.5 rounded text-sm ${
|
||||
task.status === status
|
||||
? 'bg-cyber-orange/20 border border-cyber-orange/40'
|
||||
: 'hover:bg-cyber-darker border border-transparent'
|
||||
} ${getStatusTextColor(status)} transition-all`}
|
||||
>
|
||||
{formatStatusLabel(status)} {task.status === status && '✓'}
|
||||
</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
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText, Lock } from 'lucide-react'
|
||||
import { updateTask } from '../utils/api'
|
||||
import BlockerPanel from './BlockerPanel'
|
||||
|
||||
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' }
|
||||
]
|
||||
|
||||
// Helper to format status label
|
||||
const formatStatusLabel = (status) => {
|
||||
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
// Helper to get status color
|
||||
const getStatusTextColor = (status) => {
|
||||
const lowerStatus = status.toLowerCase()
|
||||
if (lowerStatus === 'backlog') return 'text-gray-400'
|
||||
if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'text-blue-400'
|
||||
if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'text-yellow-400'
|
||||
if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'text-green-400'
|
||||
if (lowerStatus.includes('blocked')) return 'text-red-400'
|
||||
return 'text-purple-400' // default for custom statuses
|
||||
}
|
||||
|
||||
function TaskMenu({ task, onUpdate, onDelete, onEdit, projectStatuses }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showTimeEdit, setShowTimeEdit] = useState(false)
|
||||
const [showDescriptionEdit, setShowDescriptionEdit] = useState(false)
|
||||
const [showTagsEdit, setShowTagsEdit] = useState(false)
|
||||
const [showFlagEdit, setShowFlagEdit] = useState(false)
|
||||
const [showStatusEdit, setShowStatusEdit] = useState(false)
|
||||
const [showBlockerPanel, setShowBlockerPanel] = 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 [editDescription, setEditDescription] = useState(task.description || '')
|
||||
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)
|
||||
setShowDescriptionEdit(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 handleUpdateDescription = async () => {
|
||||
try {
|
||||
const description = editDescription.trim() || null
|
||||
await updateTask(task.id, { description })
|
||||
setShowDescriptionEdit(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>
|
||||
)}
|
||||
|
||||
{/* Description Edit */}
|
||||
{showDescriptionEdit ? (
|
||||
<div className="p-3 border-b border-cyber-orange/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText size={14} className="text-cyber-orange" />
|
||||
<span className="text-sm text-gray-300">Description</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Task description..."
|
||||
rows="4"
|
||||
className="w-full 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 resize-y"
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleUpdateDescription}
|
||||
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={() => setShowDescriptionEdit(false)}
|
||||
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowDescriptionEdit(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span>Edit Description</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">
|
||||
{(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => handleUpdateStatus(status)}
|
||||
className={`w-full text-left px-2 py-1.5 rounded text-sm ${
|
||||
task.status === status
|
||||
? 'bg-cyber-orange/20 border border-cyber-orange/40'
|
||||
: 'hover:bg-cyber-darker border border-transparent'
|
||||
} ${getStatusTextColor(status)} transition-all`}
|
||||
>
|
||||
{formatStatusLabel(status)} {task.status === status && '✓'}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Manage Blockers */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowBlockerPanel(true)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||
>
|
||||
<Lock size={14} />
|
||||
<span>Manage Blockers</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>
|
||||
)}
|
||||
|
||||
{/* Blocker panel modal */}
|
||||
{showBlockerPanel && (
|
||||
<BlockerPanel
|
||||
task={task}
|
||||
onClose={() => setShowBlockerPanel(false)}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TaskMenu
|
||||
|
||||
190
frontend/src/pages/ActionableView.jsx
Normal file
190
frontend/src/pages/ActionableView.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Zap, Clock, RefreshCw, ChevronRight, Check } from 'lucide-react'
|
||||
import { getActionableTasks, updateTask } from '../utils/api'
|
||||
|
||||
const FLAG_DOT = {
|
||||
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',
|
||||
}
|
||||
|
||||
const formatTime = (minutes) => {
|
||||
if (!minutes) return null
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
if (h && m) return `${h}h ${m}m`
|
||||
if (h) return `${h}h`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
const formatStatusLabel = (status) =>
|
||||
status.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'in_progress' || s.includes('progress')) return 'text-blue-400'
|
||||
if (s === 'on_hold' || s.includes('hold')) return 'text-yellow-400'
|
||||
return 'text-gray-500'
|
||||
}
|
||||
|
||||
function ActionableView() {
|
||||
const [tasks, setTasks] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [completingId, setCompletingId] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks()
|
||||
}, [])
|
||||
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
const data = await getActionableTasks()
|
||||
setTasks(data)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkDone = async (task) => {
|
||||
try {
|
||||
setCompletingId(task.id)
|
||||
await updateTask(task.id, { status: 'done' })
|
||||
// Remove from list and reload to surface newly unblocked tasks
|
||||
setTasks(prev => prev.filter(t => t.id !== task.id))
|
||||
// Reload after a short beat so the user sees the removal first
|
||||
setTimeout(() => loadTasks(), 600)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setCompletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Group by project
|
||||
const byProject = tasks.reduce((acc, task) => {
|
||||
const key = task.project_id
|
||||
if (!acc[key]) acc[key] = { name: task.project_name, tasks: [] }
|
||||
acc[key].tasks.push(task)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const projectGroups = Object.entries(byProject)
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap size={24} className="text-cyber-orange" />
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-100">What can I do right now?</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{loading ? '...' : `${tasks.length} task${tasks.length !== 1 ? 's' : ''} ready across all projects`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadTasks}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:text-gray-200 border border-cyber-orange/20 hover:border-cyber-orange/40 rounded transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 px-4 py-3 bg-red-900/30 border border-red-500/40 rounded text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && tasks.length === 0 && (
|
||||
<div className="text-center py-20 text-gray-600">
|
||||
<Zap size={40} className="mx-auto mb-4 opacity-30" />
|
||||
<p className="text-lg">Nothing actionable right now.</p>
|
||||
<p className="text-sm mt-1">All tasks are either done or blocked.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project groups */}
|
||||
<div className="space-y-8">
|
||||
{projectGroups.map(([projectId, group]) => (
|
||||
<div key={projectId}>
|
||||
{/* Project header */}
|
||||
<button
|
||||
onClick={() => navigate(`/project/${projectId}`)}
|
||||
className="flex items-center gap-2 mb-3 text-cyber-orange hover:text-cyber-orange-bright transition-colors group"
|
||||
>
|
||||
<span className="text-sm font-semibold uppercase tracking-wider">{group.name}</span>
|
||||
<ChevronRight size={14} className="opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
|
||||
{/* Task cards */}
|
||||
<div className="space-y-2">
|
||||
{group.tasks.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`flex items-center gap-3 px-4 py-3 bg-cyber-darkest border border-cyber-orange/20 rounded-lg hover:border-cyber-orange/40 transition-all ${
|
||||
completingId === task.id ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Done button */}
|
||||
<button
|
||||
onClick={() => handleMarkDone(task)}
|
||||
disabled={completingId === task.id}
|
||||
className="flex-shrink-0 w-6 h-6 rounded-full border-2 border-cyber-orange/40 hover:border-green-400 hover:bg-green-400/10 flex items-center justify-center transition-all group"
|
||||
title="Mark as done"
|
||||
>
|
||||
<Check size={12} className="text-transparent group-hover:text-green-400 transition-colors" />
|
||||
</button>
|
||||
|
||||
{/* Flag dot */}
|
||||
{task.flag_color && (
|
||||
<span className={`flex-shrink-0 w-2 h-2 rounded-full ${FLAG_DOT[task.flag_color] || 'bg-gray-500'}`} />
|
||||
)}
|
||||
|
||||
{/* Title + meta */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-gray-100">{task.title}</span>
|
||||
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
||||
{task.status !== 'backlog' && (
|
||||
<span className={`text-xs ${getStatusColor(task.status)}`}>
|
||||
{formatStatusLabel(task.status)}
|
||||
</span>
|
||||
)}
|
||||
{task.estimated_minutes && (
|
||||
<span className="flex items-center gap-1 text-xs text-gray-600">
|
||||
<Clock size={10} />
|
||||
{formatTime(task.estimated_minutes)}
|
||||
</span>
|
||||
)}
|
||||
{task.tags && task.tags.map(tag => (
|
||||
<span key={tag} className="text-xs text-cyber-orange/60 bg-cyber-orange/5 px-1.5 py-0.5 rounded">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionableView
|
||||
@@ -1,76 +1,85 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
|
||||
async function fetchAPI(endpoint, options = {}) {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Projects
|
||||
export const getProjects = (archived = null) => {
|
||||
const params = new URLSearchParams();
|
||||
if (archived !== null) {
|
||||
params.append('archived', archived);
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return fetchAPI(`/projects${queryString ? `?${queryString}` : ''}`);
|
||||
};
|
||||
export const getProject = (id) => fetchAPI(`/projects/${id}`);
|
||||
export const createProject = (data) => fetchAPI('/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
export const updateProject = (id, data) => fetchAPI(`/projects/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
export const deleteProject = (id) => fetchAPI(`/projects/${id}`, { method: 'DELETE' });
|
||||
export const archiveProject = (id) => updateProject(id, { is_archived: true });
|
||||
export const unarchiveProject = (id) => updateProject(id, { is_archived: false });
|
||||
|
||||
// Tasks
|
||||
export const getProjectTasks = (projectId) => fetchAPI(`/projects/${projectId}/tasks`);
|
||||
export const getProjectTaskTree = (projectId) => fetchAPI(`/projects/${projectId}/tasks/tree`);
|
||||
export const getTasksByStatus = (projectId, status) =>
|
||||
fetchAPI(`/projects/${projectId}/tasks/by-status/${status}`);
|
||||
|
||||
export const getTask = (id) => fetchAPI(`/tasks/${id}`);
|
||||
export const createTask = (data) => fetchAPI('/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
export const updateTask = (id, data) => fetchAPI(`/tasks/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
export const deleteTask = (id) => fetchAPI(`/tasks/${id}`, { method: 'DELETE' });
|
||||
|
||||
// JSON Import
|
||||
export const importJSON = (data) => fetchAPI('/import-json', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
// Search
|
||||
export const searchTasks = (query, projectIds = null) => {
|
||||
const params = new URLSearchParams({ query });
|
||||
if (projectIds && projectIds.length > 0) {
|
||||
params.append('project_ids', projectIds.join(','));
|
||||
}
|
||||
return fetchAPI(`/search?${params.toString()}`);
|
||||
};
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
|
||||
async function fetchAPI(endpoint, options = {}) {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Projects
|
||||
export const getProjects = (archived = null) => {
|
||||
const params = new URLSearchParams();
|
||||
if (archived !== null) {
|
||||
params.append('archived', archived);
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return fetchAPI(`/projects${queryString ? `?${queryString}` : ''}`);
|
||||
};
|
||||
export const getProject = (id) => fetchAPI(`/projects/${id}`);
|
||||
export const createProject = (data) => fetchAPI('/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
export const updateProject = (id, data) => fetchAPI(`/projects/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
export const deleteProject = (id) => fetchAPI(`/projects/${id}`, { method: 'DELETE' });
|
||||
export const archiveProject = (id) => updateProject(id, { is_archived: true });
|
||||
export const unarchiveProject = (id) => updateProject(id, { is_archived: false });
|
||||
|
||||
// Tasks
|
||||
export const getProjectTasks = (projectId) => fetchAPI(`/projects/${projectId}/tasks`);
|
||||
export const getProjectTaskTree = (projectId) => fetchAPI(`/projects/${projectId}/tasks/tree`);
|
||||
export const getTasksByStatus = (projectId, status) =>
|
||||
fetchAPI(`/projects/${projectId}/tasks/by-status/${status}`);
|
||||
|
||||
export const getTask = (id) => fetchAPI(`/tasks/${id}`);
|
||||
export const createTask = (data) => fetchAPI('/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
export const updateTask = (id, data) => fetchAPI(`/tasks/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
export const deleteTask = (id) => fetchAPI(`/tasks/${id}`, { method: 'DELETE' });
|
||||
|
||||
// JSON Import
|
||||
export const importJSON = (data) => fetchAPI('/import-json', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
// Blockers
|
||||
export const getTaskBlockers = (taskId) => fetchAPI(`/tasks/${taskId}/blockers`);
|
||||
export const getTaskBlocking = (taskId) => fetchAPI(`/tasks/${taskId}/blocking`);
|
||||
export const addBlocker = (taskId, blockerId) => fetchAPI(`/tasks/${taskId}/blockers/${blockerId}`, { method: 'POST' });
|
||||
export const removeBlocker = (taskId, blockerId) => fetchAPI(`/tasks/${taskId}/blockers/${blockerId}`, { method: 'DELETE' });
|
||||
|
||||
// Actionable tasks (no incomplete blockers, not done)
|
||||
export const getActionableTasks = () => fetchAPI('/actionable');
|
||||
|
||||
// Search
|
||||
export const searchTasks = (query, projectIds = null) => {
|
||||
const params = new URLSearchParams({ query });
|
||||
if (projectIds && projectIds.length > 0) {
|
||||
params.append('project_ids', projectIds.join(','));
|
||||
}
|
||||
return fetchAPI(`/search?${params.toString()}`);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user