Merge remote branch and resolve conflicts with BIT rename
Kept remote's pydantic-settings, env_file, SearchBar, and new components. Applied BIT/Break It Down naming throughout conflicted files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
18
frontend/.env.example
Normal file
18
frontend/.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# API Configuration
|
||||
# Base URL for API requests (relative path used in production)
|
||||
VITE_API_BASE_URL=/api
|
||||
|
||||
# Backend API URL (used for development proxy)
|
||||
VITE_API_URL=http://localhost:8000
|
||||
|
||||
# Development Configuration
|
||||
# Port for Vite development server
|
||||
VITE_DEV_PORT=5173
|
||||
|
||||
# Application Configuration
|
||||
# Application version displayed in UI
|
||||
VITE_APP_VERSION=0.1.5
|
||||
|
||||
# UI/UX Configuration
|
||||
# Search input debounce delay in milliseconds
|
||||
VITE_SEARCH_DEBOUNCE_MS=300
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "tesseract-frontend",
|
||||
"name": "bit-frontend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tesseract-frontend",
|
||||
"name": "bit-frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.303.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 (
|
||||
@@ -12,9 +13,10 @@ function App() {
|
||||
<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">v0.1.3</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>
|
||||
|
||||
@@ -1,27 +1,87 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Edit2, Trash2, Check, X } from 'lucide-react'
|
||||
import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp } from 'lucide-react'
|
||||
import {
|
||||
getProjectTasks,
|
||||
createTask,
|
||||
updateTask,
|
||||
deleteTask
|
||||
} from '../utils/api'
|
||||
import { formatTimeWithTotal } from '../utils/format'
|
||||
import TaskMenu from './TaskMenu'
|
||||
import TaskForm from './TaskForm'
|
||||
|
||||
const STATUSES = [
|
||||
{ key: 'backlog', label: 'Backlog', color: 'border-gray-600' },
|
||||
{ key: 'in_progress', label: 'In Progress', color: 'border-blue-500' },
|
||||
{ key: 'blocked', label: 'Blocked', color: 'border-red-500' },
|
||||
{ key: 'done', label: 'Done', color: 'border-green-500' }
|
||||
]
|
||||
// Helper to format status label
|
||||
const formatStatusLabel = (status) => {
|
||||
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
||||
// Helper to get status color based on common patterns
|
||||
const getStatusColor = (status) => {
|
||||
const lowerStatus = status.toLowerCase()
|
||||
if (lowerStatus === 'backlog') return 'border-gray-600'
|
||||
if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'border-blue-500'
|
||||
if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'border-yellow-500'
|
||||
if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'border-green-500'
|
||||
if (lowerStatus.includes('blocked')) return 'border-red-500'
|
||||
return 'border-purple-500' // default for custom statuses
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
// Helper function to get all descendant tasks recursively
|
||||
function getAllDescendants(taskId, allTasks) {
|
||||
const children = allTasks.filter(t => t.parent_task_id === taskId)
|
||||
let descendants = [...children]
|
||||
|
||||
for (const child of children) {
|
||||
descendants = descendants.concat(getAllDescendants(child.id, allTasks))
|
||||
}
|
||||
|
||||
return descendants
|
||||
}
|
||||
|
||||
// Helper function to get all descendant tasks of a parent in a specific status
|
||||
function getDescendantsInStatus(taskId, allTasks, status) {
|
||||
const children = allTasks.filter(t => t.parent_task_id === taskId)
|
||||
let descendants = []
|
||||
|
||||
for (const child of children) {
|
||||
if (child.status === status) {
|
||||
descendants.push(child)
|
||||
}
|
||||
// Recursively get descendants
|
||||
descendants = descendants.concat(getDescendantsInStatus(child.id, allTasks, status))
|
||||
}
|
||||
|
||||
return descendants
|
||||
}
|
||||
|
||||
// Helper function to check if a task has any descendants in a status
|
||||
function hasDescendantsInStatus(taskId, allTasks, status) {
|
||||
return getDescendantsInStatus(taskId, allTasks, status).length > 0
|
||||
}
|
||||
|
||||
function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards, projectStatuses, projectId }) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editTitle, setEditTitle] = useState(task.title)
|
||||
const [showAddSubtask, setShowAddSubtask] = useState(false)
|
||||
|
||||
// Find parent task if this is a subtask
|
||||
const parentTask = task.parent_task_id
|
||||
? allTasks.find(t => t.id === task.parent_task_id)
|
||||
: null
|
||||
// Use global expanded state
|
||||
const isExpanded = expandedCards[task.id] || false
|
||||
const toggleExpanded = () => {
|
||||
setExpandedCards(prev => ({
|
||||
...prev,
|
||||
[task.id]: !prev[task.id]
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
@@ -43,85 +103,209 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddSubtask = async (taskData) => {
|
||||
try {
|
||||
await createTask({
|
||||
project_id: parseInt(projectId),
|
||||
parent_task_id: task.id,
|
||||
title: taskData.title,
|
||||
description: taskData.description,
|
||||
status: taskData.status,
|
||||
tags: taskData.tags,
|
||||
estimated_minutes: taskData.estimated_minutes,
|
||||
flag_color: taskData.flag_color
|
||||
})
|
||||
setShowAddSubtask(false)
|
||||
setExpandedCards(prev => ({ ...prev, [task.id]: true }))
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// For parent cards, get children in this column's status
|
||||
const childrenInColumn = isParent ? getDescendantsInStatus(task.id, allTasks, columnStatus) : []
|
||||
const totalChildren = isParent ? allTasks.filter(t => t.parent_task_id === task.id).length : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={!isEditing}
|
||||
onDragStart={(e) => onDragStart(e, task)}
|
||||
className="bg-cyber-darkest border border-cyber-orange/30 rounded-lg p-3 mb-2 cursor-move hover:border-cyber-orange/60 transition-all group"
|
||||
>
|
||||
{isEditing ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
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
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="text-green-400 hover:text-green-300"
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(false)
|
||||
setEditTitle(task.title)
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-300"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="text-gray-200 text-sm">{task.title}</div>
|
||||
{parentTask && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
↳ subtask of: <span className="text-cyber-orange">{parentTask.title}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="text-gray-400 hover:text-gray-200"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="text-gray-600 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div
|
||||
draggable={!isEditing}
|
||||
onDragStart={(e) => onDragStart(e, task, isParent)}
|
||||
className={`${
|
||||
isParent
|
||||
? 'bg-cyber-darker border-2 border-cyber-orange/50'
|
||||
: 'bg-cyber-darkest border border-cyber-orange/30'
|
||||
} rounded-lg p-3 cursor-move hover:border-cyber-orange/60 transition-all group`}
|
||||
>
|
||||
{isEditing ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
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
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="text-green-400 hover:text-green-300"
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(false)
|
||||
setEditTitle(task.title)
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-300"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Expand/collapse for parent cards */}
|
||||
{isParent && childrenInColumn.length > 0 && (
|
||||
<button
|
||||
onClick={toggleExpanded}
|
||||
className="text-cyber-orange hover:text-cyber-orange-bright"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<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={`${isParent ? 'font-semibold text-cyber-orange' : 'text-gray-200'}`}>
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Parent card info: show subtask count in this column */}
|
||||
{isParent && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{childrenInColumn.length} of {totalChildren} subtask{totalChildren !== 1 ? 's' : ''} in this column
|
||||
</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 ${task.status === 'done' ? 'line-through' : ''}`}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{task.description && (
|
||||
<div className="mt-2 text-xs text-gray-400 italic">
|
||||
{task.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setShowAddSubtask(true)}
|
||||
className="text-cyber-orange hover:text-cyber-orange-bright p-1"
|
||||
title="Add subtask"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<TaskMenu
|
||||
task={task}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={handleDelete}
|
||||
onEdit={() => setIsEditing(true)}
|
||||
projectStatuses={projectStatuses}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Subtask Form */}
|
||||
{showAddSubtask && (
|
||||
<div className="ml-6 mt-2">
|
||||
<TaskForm
|
||||
onSubmit={handleAddSubtask}
|
||||
onCancel={() => setShowAddSubtask(false)}
|
||||
submitLabel="Add Subtask"
|
||||
projectStatuses={projectStatuses}
|
||||
defaultStatus={columnStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded children */}
|
||||
{isParent && isExpanded && childrenInColumn.length > 0 && (
|
||||
<div className="ml-6 mt-2 space-y-2">
|
||||
{childrenInColumn.map(child => (
|
||||
<TaskCard
|
||||
key={child.id}
|
||||
task={child}
|
||||
allTasks={allTasks}
|
||||
onUpdate={onUpdate}
|
||||
onDragStart={onDragStart}
|
||||
isParent={false}
|
||||
columnStatus={columnStatus}
|
||||
expandedCards={expandedCards}
|
||||
setExpandedCards={setExpandedCards}
|
||||
projectStatuses={projectStatuses}
|
||||
projectId={projectId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, onDragOver }) {
|
||||
function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards, projectStatuses }) {
|
||||
const [showAddTask, setShowAddTask] = useState(false)
|
||||
const [newTaskTitle, setNewTaskTitle] = useState('')
|
||||
|
||||
const handleAddTask = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!newTaskTitle.trim()) return
|
||||
|
||||
const handleAddTask = async (taskData) => {
|
||||
try {
|
||||
await createTask({
|
||||
project_id: parseInt(projectId),
|
||||
parent_task_id: null,
|
||||
title: newTaskTitle,
|
||||
status: status.key
|
||||
title: taskData.title,
|
||||
description: taskData.description,
|
||||
status: taskData.status,
|
||||
tags: taskData.tags,
|
||||
estimated_minutes: taskData.estimated_minutes,
|
||||
flag_color: taskData.flag_color
|
||||
})
|
||||
setNewTaskTitle('')
|
||||
setShowAddTask(false)
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
@@ -129,16 +313,37 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
|
||||
}
|
||||
}
|
||||
|
||||
// Get tasks to display in this column:
|
||||
// 1. All leaf tasks (no children) with this status
|
||||
// 2. All parent tasks that have at least one descendant with this status
|
||||
const leafTasks = allTasks.filter(t => {
|
||||
const hasChildren = allTasks.some(child => child.parent_task_id === t.id)
|
||||
return !hasChildren && t.status === status.key
|
||||
})
|
||||
|
||||
const parentTasks = allTasks.filter(t => {
|
||||
const hasChildren = allTasks.some(child => child.parent_task_id === t.id)
|
||||
return hasChildren && hasDescendantsInStatus(t.id, allTasks, status.key)
|
||||
})
|
||||
|
||||
// Only show root-level parents (not nested parents)
|
||||
const rootParents = parentTasks.filter(t => !t.parent_task_id)
|
||||
|
||||
// Only show root-level leaf tasks (leaf tasks without parents)
|
||||
const rootLeafTasks = leafTasks.filter(t => !t.parent_task_id)
|
||||
|
||||
const displayTasks = [...rootParents, ...rootLeafTasks]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 min-w-[280px] bg-cyber-darker rounded-lg p-4 border-t-4 ${status.color}"
|
||||
className={`flex-1 min-w-[280px] bg-cyber-darker rounded-lg p-4 border-t-4 ${status.color}`}
|
||||
onDrop={(e) => onDrop(e, status.key)}
|
||||
onDragOver={onDragOver}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-semibold text-gray-200">
|
||||
{status.label}
|
||||
<span className="ml-2 text-xs text-gray-500">({tasks.length})</span>
|
||||
<span className="ml-2 text-xs text-gray-500">({displayTasks.length})</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowAddTask(true)}
|
||||
@@ -150,55 +355,56 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
|
||||
|
||||
{showAddTask && (
|
||||
<div className="mb-3">
|
||||
<form onSubmit={handleAddTask}>
|
||||
<input
|
||||
type="text"
|
||||
value={newTaskTitle}
|
||||
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>
|
||||
<TaskForm
|
||||
onSubmit={handleAddTask}
|
||||
onCancel={() => setShowAddTask(false)}
|
||||
submitLabel="Add Task"
|
||||
projectStatuses={projectStatuses}
|
||||
defaultStatus={status.key}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{tasks.map(task => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
allTasks={allTasks}
|
||||
onUpdate={onUpdate}
|
||||
onDragStart={(e, task) => {
|
||||
e.dataTransfer.setData('taskId', task.id.toString())
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{displayTasks.map(task => {
|
||||
const isParent = allTasks.some(t => t.parent_task_id === task.id)
|
||||
return (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
allTasks={allTasks}
|
||||
onUpdate={onUpdate}
|
||||
onDragStart={(e, task, isParent) => {
|
||||
e.dataTransfer.setData('taskId', task.id.toString())
|
||||
e.dataTransfer.setData('isParent', isParent.toString())
|
||||
}}
|
||||
isParent={isParent}
|
||||
columnStatus={status.key}
|
||||
expandedCards={expandedCards}
|
||||
setExpandedCards={setExpandedCards}
|
||||
projectStatuses={projectStatuses}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KanbanView({ projectId }) {
|
||||
function KanbanView({ projectId, project }) {
|
||||
const [allTasks, setAllTasks] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [expandedCards, setExpandedCards] = useState({})
|
||||
|
||||
// Get statuses from project, or use defaults
|
||||
const statuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
|
||||
const statusesWithMeta = statuses.map(status => ({
|
||||
key: status,
|
||||
label: formatStatusLabel(status),
|
||||
color: getStatusColor(status)
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks()
|
||||
@@ -216,6 +422,19 @@ function KanbanView({ projectId }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleExpandAll = () => {
|
||||
const parentTasks = allTasks.filter(t => allTasks.some(child => child.parent_task_id === t.id))
|
||||
const newExpandedState = {}
|
||||
parentTasks.forEach(task => {
|
||||
newExpandedState[task.id] = true
|
||||
})
|
||||
setExpandedCards(newExpandedState)
|
||||
}
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
setExpandedCards({})
|
||||
}
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
@@ -223,11 +442,22 @@ function KanbanView({ projectId }) {
|
||||
const handleDrop = async (e, newStatus) => {
|
||||
e.preventDefault()
|
||||
const taskId = parseInt(e.dataTransfer.getData('taskId'))
|
||||
const isParent = e.dataTransfer.getData('isParent') === 'true'
|
||||
|
||||
if (!taskId) return
|
||||
|
||||
try {
|
||||
// Update the dragged task
|
||||
await updateTask(taskId, { status: newStatus })
|
||||
|
||||
// If it's a parent task, update all descendants
|
||||
if (isParent) {
|
||||
const descendants = getAllDescendants(taskId, allTasks)
|
||||
for (const descendant of descendants) {
|
||||
await updateTask(descendant.id, { status: newStatus })
|
||||
}
|
||||
}
|
||||
|
||||
loadTasks()
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`)
|
||||
@@ -244,19 +474,39 @@ function KanbanView({ projectId }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-300 mb-4">Kanban Board</h3>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xl font-semibold text-gray-300">Kanban Board (Nested View)</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleExpandAll}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-cyber-darker border border-cyber-orange/30 text-gray-300 rounded hover:border-cyber-orange/60 hover:bg-cyber-dark transition-colors"
|
||||
>
|
||||
<ChevronsDown size={16} />
|
||||
Expand All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCollapseAll}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-cyber-darker border border-cyber-orange/30 text-gray-300 rounded hover:border-cyber-orange/60 hover:bg-cyber-dark transition-colors"
|
||||
>
|
||||
<ChevronsUp size={16} />
|
||||
Collapse All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{STATUSES.map(status => (
|
||||
{statusesWithMeta.map(status => (
|
||||
<KanbanColumn
|
||||
key={status.key}
|
||||
status={status}
|
||||
tasks={allTasks.filter(t => t.status === status.key)}
|
||||
allTasks={allTasks}
|
||||
projectId={projectId}
|
||||
onUpdate={loadTasks}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
expandedCards={expandedCards}
|
||||
setExpandedCards={setExpandedCards}
|
||||
projectStatuses={statuses}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
298
frontend/src/components/ProjectSettings.jsx
Normal file
298
frontend/src/components/ProjectSettings.jsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, GripVertical, Plus, Trash2, Check, AlertTriangle } from 'lucide-react'
|
||||
import { updateProject, getProjectTasks } from '../utils/api'
|
||||
|
||||
function ProjectSettings({ project, onClose, onUpdate }) {
|
||||
const [statuses, setStatuses] = useState(project.statuses || [])
|
||||
const [editingIndex, setEditingIndex] = useState(null)
|
||||
const [editingValue, setEditingValue] = useState('')
|
||||
const [draggedIndex, setDraggedIndex] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [taskCounts, setTaskCounts] = useState({})
|
||||
const [deleteWarning, setDeleteWarning] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadTaskCounts()
|
||||
}, [])
|
||||
|
||||
const loadTaskCounts = async () => {
|
||||
try {
|
||||
const tasks = await getProjectTasks(project.id)
|
||||
const counts = {}
|
||||
statuses.forEach(status => {
|
||||
counts[status] = tasks.filter(t => t.status === status).length
|
||||
})
|
||||
setTaskCounts(counts)
|
||||
} catch (err) {
|
||||
console.error('Failed to load task counts:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragStart = (index) => {
|
||||
setDraggedIndex(index)
|
||||
}
|
||||
|
||||
const handleDragOver = (e, index) => {
|
||||
e.preventDefault()
|
||||
if (draggedIndex === null || draggedIndex === index) return
|
||||
|
||||
const newStatuses = [...statuses]
|
||||
const draggedItem = newStatuses[draggedIndex]
|
||||
newStatuses.splice(draggedIndex, 1)
|
||||
newStatuses.splice(index, 0, draggedItem)
|
||||
|
||||
setStatuses(newStatuses)
|
||||
setDraggedIndex(index)
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null)
|
||||
}
|
||||
|
||||
const handleAddStatus = () => {
|
||||
const newStatus = `new_status_${Date.now()}`
|
||||
setStatuses([...statuses, newStatus])
|
||||
setEditingIndex(statuses.length)
|
||||
setEditingValue(newStatus)
|
||||
}
|
||||
|
||||
const handleStartEdit = (index) => {
|
||||
setEditingIndex(index)
|
||||
setEditingValue(statuses[index])
|
||||
}
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (!editingValue.trim()) {
|
||||
setError('Status name cannot be empty')
|
||||
return
|
||||
}
|
||||
|
||||
const trimmedValue = editingValue.trim().toLowerCase().replace(/\s+/g, '_')
|
||||
|
||||
if (statuses.some((s, i) => i !== editingIndex && s === trimmedValue)) {
|
||||
setError('Status name already exists')
|
||||
return
|
||||
}
|
||||
|
||||
const newStatuses = [...statuses]
|
||||
newStatuses[editingIndex] = trimmedValue
|
||||
setStatuses(newStatuses)
|
||||
setEditingIndex(null)
|
||||
setError('')
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
// If it's a new status that was never saved, remove it
|
||||
if (statuses[editingIndex].startsWith('new_status_')) {
|
||||
const newStatuses = statuses.filter((_, i) => i !== editingIndex)
|
||||
setStatuses(newStatuses)
|
||||
}
|
||||
setEditingIndex(null)
|
||||
setError('')
|
||||
}
|
||||
|
||||
const handleDeleteStatus = (index) => {
|
||||
const statusToDelete = statuses[index]
|
||||
const taskCount = taskCounts[statusToDelete] || 0
|
||||
|
||||
if (taskCount > 0) {
|
||||
setDeleteWarning({ index, status: statusToDelete, count: taskCount })
|
||||
return
|
||||
}
|
||||
|
||||
if (statuses.length === 1) {
|
||||
setError('Cannot delete the last status')
|
||||
return
|
||||
}
|
||||
|
||||
const newStatuses = statuses.filter((_, i) => i !== index)
|
||||
setStatuses(newStatuses)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (statuses.length === 0) {
|
||||
setError('Project must have at least one status')
|
||||
return
|
||||
}
|
||||
|
||||
if (editingIndex !== null) {
|
||||
setError('Please save or cancel the status you are editing')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateProject(project.id, { statuses })
|
||||
onUpdate()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-6 border-b border-cyber-orange/20">
|
||||
<h2 className="text-2xl font-bold text-gray-100">Project Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-200"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-200 mb-2">Project Details</h3>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-sm text-gray-400">Name:</span>
|
||||
<span className="ml-2 text-gray-200">{project.name}</span>
|
||||
</div>
|
||||
{project.description && (
|
||||
<div>
|
||||
<span className="text-sm text-gray-400">Description:</span>
|
||||
<span className="ml-2 text-gray-200">{project.description}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-200 mb-2">Status Workflow</h3>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Drag to reorder, click to rename. Tasks will appear in Kanban columns in this order.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
{statuses.map((status, index) => (
|
||||
<div
|
||||
key={index}
|
||||
draggable={editingIndex !== index}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`flex items-center gap-3 p-3 bg-cyber-darker border border-cyber-orange/30 rounded ${
|
||||
draggedIndex === index ? 'opacity-50' : ''
|
||||
} ${editingIndex !== index ? 'cursor-move' : ''}`}
|
||||
>
|
||||
{editingIndex !== index && (
|
||||
<GripVertical size={18} className="text-gray-500" />
|
||||
)}
|
||||
|
||||
{editingIndex === index ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveEdit()
|
||||
if (e.key === 'Escape') handleCancelEdit()
|
||||
}}
|
||||
className="flex-1 px-2 py-1 bg-cyber-darkest border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
className="text-green-400 hover:text-green-300"
|
||||
>
|
||||
<Check size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="text-gray-400 hover:text-gray-300"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleStartEdit(index)}
|
||||
className="flex-1 text-left text-gray-200 hover:text-cyber-orange"
|
||||
>
|
||||
{status.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}
|
||||
</button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{taskCounts[status] || 0} task{taskCounts[status] === 1 ? '' : 's'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDeleteStatus(index)}
|
||||
className="text-gray-400 hover:text-red-400"
|
||||
disabled={statuses.length === 1}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAddStatus}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-cyber-darker border border-cyber-orange/30 text-gray-300 rounded hover:border-cyber-orange/60 hover:bg-cyber-dark transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 p-6 border-t border-cyber-orange/20">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-400 hover:text-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold transition-colors"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Delete Warning Dialog */}
|
||||
{deleteWarning && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<div className="bg-cyber-darkest border border-red-500/50 rounded-lg p-6 max-w-md">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertTriangle className="text-red-400 flex-shrink-0" size={24} />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-2">Cannot Delete Status</h3>
|
||||
<p className="text-sm text-gray-300">
|
||||
The status "{deleteWarning.status}" has {deleteWarning.count} task{deleteWarning.count === 1 ? '' : 's'}.
|
||||
Please move or delete those tasks first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setDeleteWarning(null)}
|
||||
className="px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectSettings
|
||||
263
frontend/src/components/SearchBar.jsx
Normal file
263
frontend/src/components/SearchBar.jsx
Normal file
@@ -0,0 +1,263 @@
|
||||
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 debounceMs = parseInt(import.meta.env.VITE_SEARCH_DEBOUNCE_MS || '300')
|
||||
const timeoutId = setTimeout(() => {
|
||||
handleSearch(query)
|
||||
}, debounceMs)
|
||||
|
||||
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
|
||||
182
frontend/src/components/TaskForm.jsx
Normal file
182
frontend/src/components/TaskForm.jsx
Normal file
@@ -0,0 +1,182 @@
|
||||
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' }
|
||||
]
|
||||
|
||||
// Helper to format status label
|
||||
const formatStatusLabel = (status) => {
|
||||
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
function TaskForm({ onSubmit, onCancel, submitLabel = "Add", projectStatuses = null, defaultStatus = "backlog" }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [tags, setTags] = useState('')
|
||||
const [hours, setHours] = useState('')
|
||||
const [minutes, setMinutes] = useState('')
|
||||
const [flagColor, setFlagColor] = useState(null)
|
||||
const [status, setStatus] = useState(defaultStatus)
|
||||
|
||||
// Use provided statuses or fall back to defaults
|
||||
const statuses = projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']
|
||||
|
||||
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(),
|
||||
description: description.trim() || null,
|
||||
tags: tagList && tagList.length > 0 ? tagList : null,
|
||||
estimated_minutes: totalMinutes > 0 ? totalMinutes : null,
|
||||
flag_color: flagColor,
|
||||
status: status
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional task description..."
|
||||
rows="3"
|
||||
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 resize-y"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Status</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
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"
|
||||
>
|
||||
{statuses.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{formatStatusLabel(s)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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
|
||||
405
frontend/src/components/TaskMenu.jsx
Normal file
405
frontend/src/components/TaskMenu.jsx
Normal file
@@ -0,0 +1,405 @@
|
||||
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
|
||||
@@ -3,10 +3,10 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Check,
|
||||
X
|
||||
X,
|
||||
Flag,
|
||||
Clock
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
getProjectTaskTree,
|
||||
@@ -14,28 +14,42 @@ import {
|
||||
updateTask,
|
||||
deleteTask
|
||||
} from '../utils/api'
|
||||
import { formatTimeWithTotal } from '../utils/format'
|
||||
import TaskMenu from './TaskMenu'
|
||||
import TaskForm from './TaskForm'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
backlog: 'text-gray-400',
|
||||
in_progress: 'text-blue-400',
|
||||
blocked: 'text-red-400',
|
||||
done: 'text-green-400'
|
||||
// Helper to format status label
|
||||
const formatStatusLabel = (status) => {
|
||||
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
const STATUS_LABELS = {
|
||||
backlog: 'Backlog',
|
||||
in_progress: 'In Progress',
|
||||
blocked: 'Blocked',
|
||||
done: 'Done'
|
||||
// Helper to get status color
|
||||
const getStatusColor = (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 TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
||||
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, projectStatuses }) {
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editTitle, setEditTitle] = useState(task.title)
|
||||
const [editStatus, setEditStatus] = useState(task.status)
|
||||
const [showAddSubtask, setShowAddSubtask] = useState(false)
|
||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('')
|
||||
|
||||
const hasSubtasks = task.subtasks && task.subtasks.length > 0
|
||||
|
||||
@@ -62,18 +76,18 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddSubtask = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!newSubtaskTitle.trim()) return
|
||||
|
||||
const handleAddSubtask = async (taskData) => {
|
||||
try {
|
||||
await createTask({
|
||||
project_id: parseInt(projectId),
|
||||
parent_task_id: task.id,
|
||||
title: newSubtaskTitle,
|
||||
status: 'backlog'
|
||||
title: taskData.title,
|
||||
description: taskData.description,
|
||||
status: taskData.status,
|
||||
tags: taskData.tags,
|
||||
estimated_minutes: taskData.estimated_minutes,
|
||||
flag_color: taskData.flag_color
|
||||
})
|
||||
setNewSubtaskTitle('')
|
||||
setShowAddSubtask(false)
|
||||
setIsExpanded(true)
|
||||
onUpdate()
|
||||
@@ -114,10 +128,9 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
||||
onChange={(e) => setEditStatus(e.target.value)}
|
||||
className="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"
|
||||
>
|
||||
<option value="backlog">Backlog</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
<option value="done">Done</option>
|
||||
{(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map(status => (
|
||||
<option key={status} value={status}>{formatStatusLabel(status)}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
@@ -139,10 +152,50 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
<span className="text-gray-200">{task.title}</span>
|
||||
<span className={`ml-3 text-xs ${STATUS_COLORS[task.status]}`}>
|
||||
{STATUS_LABELS[task.status]}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Flag indicator */}
|
||||
{task.flag_color && FLAG_COLORS[task.flag_color] && (
|
||||
<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 ${getStatusColor(task.status)}`}>
|
||||
{formatStatusLabel(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 ${task.status === 'done' ? 'line-through' : ''}`}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{task.description && (
|
||||
<div className="mt-2 text-xs text-gray-400 italic">
|
||||
{task.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -154,20 +207,13 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="text-gray-400 hover:text-gray-200"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="text-gray-600 hover:text-red-400"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<TaskMenu
|
||||
task={task}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={handleDelete}
|
||||
onEdit={() => setIsEditing(true)}
|
||||
projectStatuses={projectStatuses}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -176,29 +222,12 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
||||
{/* Add Subtask Form */}
|
||||
{showAddSubtask && (
|
||||
<div style={{ marginLeft: `${level * 1.5}rem` }} className="mt-2">
|
||||
<form onSubmit={handleAddSubtask} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newSubtaskTitle}
|
||||
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>
|
||||
<TaskForm
|
||||
onSubmit={handleAddSubtask}
|
||||
onCancel={() => setShowAddSubtask(false)}
|
||||
submitLabel="Add Subtask"
|
||||
projectStatuses={projectStatuses}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -212,6 +241,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
||||
projectId={projectId}
|
||||
onUpdate={onUpdate}
|
||||
level={level + 1}
|
||||
projectStatuses={projectStatuses}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -220,12 +250,12 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TreeView({ projectId }) {
|
||||
function TreeView({ projectId, project }) {
|
||||
const projectStatuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
|
||||
const [tasks, setTasks] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showAddRoot, setShowAddRoot] = useState(false)
|
||||
const [newTaskTitle, setNewTaskTitle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks()
|
||||
@@ -243,18 +273,18 @@ function TreeView({ projectId }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddRootTask = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!newTaskTitle.trim()) return
|
||||
|
||||
const handleAddRootTask = async (taskData) => {
|
||||
try {
|
||||
await createTask({
|
||||
project_id: parseInt(projectId),
|
||||
parent_task_id: null,
|
||||
title: newTaskTitle,
|
||||
status: 'backlog'
|
||||
title: taskData.title,
|
||||
description: taskData.description,
|
||||
status: taskData.status,
|
||||
tags: taskData.tags,
|
||||
estimated_minutes: taskData.estimated_minutes,
|
||||
flag_color: taskData.flag_color
|
||||
})
|
||||
setNewTaskTitle('')
|
||||
setShowAddRoot(false)
|
||||
loadTasks()
|
||||
} catch (err) {
|
||||
@@ -285,29 +315,12 @@ function TreeView({ projectId }) {
|
||||
|
||||
{showAddRoot && (
|
||||
<div className="mb-4">
|
||||
<form onSubmit={handleAddRootTask} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTaskTitle}
|
||||
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>
|
||||
<TaskForm
|
||||
onSubmit={handleAddRootTask}
|
||||
onCancel={() => setShowAddRoot(false)}
|
||||
submitLabel="Add Task"
|
||||
projectStatuses={projectStatuses}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -324,6 +337,7 @@ function TreeView({ projectId }) {
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
onUpdate={loadTasks}
|
||||
projectStatuses={projectStatuses}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Plus, Upload, Trash2 } from 'lucide-react'
|
||||
import { getProjects, createProject, deleteProject, importJSON } from '../utils/api'
|
||||
import { Plus, Upload, Trash2, Archive, ArchiveRestore } from 'lucide-react'
|
||||
import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject } from '../utils/api'
|
||||
|
||||
function ProjectList() {
|
||||
const [projects, setProjects] = useState([])
|
||||
@@ -12,15 +12,22 @@ function ProjectList() {
|
||||
const [newProjectDesc, setNewProjectDesc] = useState('')
|
||||
const [importJSON_Text, setImportJSONText] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('active') // 'active', 'archived', 'all'
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects()
|
||||
}, [])
|
||||
}, [activeTab])
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const data = await getProjects()
|
||||
setLoading(true)
|
||||
let archivedFilter = null
|
||||
if (activeTab === 'active') archivedFilter = false
|
||||
if (activeTab === 'archived') archivedFilter = true
|
||||
// 'all' tab uses null to get all projects
|
||||
|
||||
const data = await getProjects(archivedFilter)
|
||||
setProjects(data)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
@@ -72,13 +79,33 @@ function ProjectList() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchiveProject = async (projectId, e) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await archiveProject(projectId)
|
||||
setProjects(projects.filter(p => p.id !== projectId))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnarchiveProject = async (projectId, e) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await unarchiveProject(projectId)
|
||||
setProjects(projects.filter(p => p.id !== projectId))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center text-gray-400 py-12">Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-3xl font-bold text-gray-100">Projects</h2>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
@@ -98,6 +125,40 @@ function ProjectList() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-6 border-b border-cyber-orange/20">
|
||||
<button
|
||||
onClick={() => setActiveTab('active')}
|
||||
className={`px-4 py-2 font-medium transition-colors ${
|
||||
activeTab === 'active'
|
||||
? 'text-cyber-orange border-b-2 border-cyber-orange'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Active
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('archived')}
|
||||
className={`px-4 py-2 font-medium transition-colors ${
|
||||
activeTab === 'archived'
|
||||
? 'text-cyber-orange border-b-2 border-cyber-orange'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Archived
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('all')}
|
||||
className={`px-4 py-2 font-medium transition-colors ${
|
||||
activeTab === 'all'
|
||||
? 'text-cyber-orange border-b-2 border-cyber-orange'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-900/30 border border-red-500/50 rounded text-red-300">
|
||||
{error}
|
||||
@@ -106,8 +167,14 @@ function ProjectList() {
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-500">
|
||||
<p className="text-xl mb-2">No projects yet</p>
|
||||
<p className="text-sm">Create a new project or import from JSON</p>
|
||||
<p className="text-xl mb-2">
|
||||
{activeTab === 'archived' ? 'No archived projects' : 'No projects yet'}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{activeTab === 'archived'
|
||||
? 'Archive projects to keep them out of your active workspace'
|
||||
: 'Create a new project or import from JSON'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -115,18 +182,45 @@ function ProjectList() {
|
||||
<div
|
||||
key={project.id}
|
||||
onClick={() => navigate(`/project/${project.id}`)}
|
||||
className="p-6 bg-cyber-darkest border border-cyber-orange/30 rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group"
|
||||
className={`p-6 bg-cyber-darkest border rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group ${
|
||||
project.is_archived
|
||||
? 'border-gray-700 opacity-75'
|
||||
: 'border-cyber-orange/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="text-xl font-semibold text-gray-100 group-hover:text-cyber-orange transition-colors">
|
||||
{project.name}
|
||||
{project.is_archived && (
|
||||
<span className="ml-2 text-xs text-gray-500">(archived)</span>
|
||||
)}
|
||||
</h3>
|
||||
<button
|
||||
onClick={(e) => handleDeleteProject(project.id, e)}
|
||||
className="text-gray-600 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
{project.is_archived ? (
|
||||
<button
|
||||
onClick={(e) => handleUnarchiveProject(project.id, e)}
|
||||
className="text-gray-600 hover:text-cyber-orange transition-colors"
|
||||
title="Unarchive project"
|
||||
>
|
||||
<ArchiveRestore size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => handleArchiveProject(project.id, e)}
|
||||
className="text-gray-600 hover:text-yellow-400 transition-colors"
|
||||
title="Archive project"
|
||||
>
|
||||
<Archive size={18} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => handleDeleteProject(project.id, e)}
|
||||
className="text-gray-600 hover:text-red-400 transition-colors"
|
||||
title="Delete project"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{project.description && (
|
||||
<p className="text-gray-400 text-sm line-clamp-2">{project.description}</p>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, LayoutList, LayoutGrid } from 'lucide-react'
|
||||
import { ArrowLeft, LayoutList, LayoutGrid, Settings } from 'lucide-react'
|
||||
import { getProject } from '../utils/api'
|
||||
import TreeView from '../components/TreeView'
|
||||
import KanbanView from '../components/KanbanView'
|
||||
import ProjectSettings from '../components/ProjectSettings'
|
||||
|
||||
function ProjectView() {
|
||||
const { projectId } = useParams()
|
||||
@@ -12,6 +13,7 @@ function ProjectView() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [view, setView] = useState('tree') // 'tree' or 'kanban'
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadProject()
|
||||
@@ -65,37 +67,55 @@ function ProjectView() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 bg-cyber-darkest rounded-lg p-1 border border-cyber-orange/30">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex gap-2 bg-cyber-darkest rounded-lg p-1 border border-cyber-orange/30">
|
||||
<button
|
||||
onClick={() => setView('tree')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded transition-colors ${
|
||||
view === 'tree'
|
||||
? 'bg-cyber-orange text-cyber-darkest font-semibold'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
<LayoutList size={18} />
|
||||
Tree View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('kanban')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded transition-colors ${
|
||||
view === 'kanban'
|
||||
? 'bg-cyber-orange text-cyber-darkest font-semibold'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid size={18} />
|
||||
Kanban
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setView('tree')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded transition-colors ${
|
||||
view === 'tree'
|
||||
? 'bg-cyber-orange text-cyber-darkest font-semibold'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="p-2 text-gray-400 hover:text-cyber-orange transition-colors border border-cyber-orange/30 rounded-lg hover:border-cyber-orange/60"
|
||||
title="Project Settings"
|
||||
>
|
||||
<LayoutList size={18} />
|
||||
Tree View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('kanban')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded transition-colors ${
|
||||
view === 'kanban'
|
||||
? 'bg-cyber-orange text-cyber-darkest font-semibold'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid size={18} />
|
||||
Kanban
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === 'tree' ? (
|
||||
<TreeView projectId={projectId} />
|
||||
<TreeView projectId={projectId} project={project} />
|
||||
) : (
|
||||
<KanbanView projectId={projectId} />
|
||||
<KanbanView projectId={projectId} project={project} />
|
||||
)}
|
||||
|
||||
{showSettings && (
|
||||
<ProjectSettings
|
||||
project={project}
|
||||
onClose={() => setShowSettings(false)}
|
||||
onUpdate={loadProject}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const API_BASE = '/api';
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
|
||||
async function fetchAPI(endpoint, options = {}) {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
@@ -22,7 +22,14 @@ async function fetchAPI(endpoint, options = {}) {
|
||||
}
|
||||
|
||||
// Projects
|
||||
export const getProjects = () => fetchAPI('/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',
|
||||
@@ -33,6 +40,8 @@ export const updateProject = (id, data) => fetchAPI(`/projects/${id}`, {
|
||||
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`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Format minutes into display string
|
||||
// Format minutes into display string (e.g., "1h 30m" or "45m")
|
||||
export function formatTime(minutes) {
|
||||
if (!minutes || minutes === 0) return null;
|
||||
|
||||
@@ -6,8 +6,14 @@ export function formatTime(minutes) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
const hours = minutes / 60;
|
||||
return `${hours.toFixed(1)}h`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (mins === 0) {
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
|
||||
// Format tags as comma-separated string
|
||||
@@ -15,3 +21,66 @@ export function formatTags(tags) {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: parseInt(env.VITE_DEV_PORT || '5173'),
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: env.VITE_API_URL || 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user