diff --git a/backend/app/crud.py b/backend/app/crud.py
index 17b7924..9e2e7ca 100644
--- a/backend/app/crud.py
+++ b/backend/app/crud.py
@@ -5,7 +5,12 @@ from . import models, schemas
# Project CRUD
def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project:
- db_project = models.Project(**project.model_dump())
+ project_data = project.model_dump()
+ # Ensure statuses has a default value if not provided
+ if project_data.get("statuses") is None:
+ project_data["statuses"] = models.DEFAULT_STATUSES
+
+ db_project = models.Project(**project_data)
db.add(db_project)
db.commit()
db.refresh(db_project)
@@ -47,6 +52,11 @@ def delete_project(db: Session, project_id: int) -> bool:
# Task CRUD
def create_task(db: Session, task: schemas.TaskCreate) -> models.Task:
+ # Validate status against project's statuses
+ project = get_project(db, task.project_id)
+ if project and task.status not in project.statuses:
+ raise ValueError(f"Invalid status '{task.status}'. Must be one of: {', '.join(project.statuses)}")
+
# Get max sort_order for siblings
if task.parent_task_id:
max_order = db.query(models.Task).filter(
@@ -104,13 +114,13 @@ def check_and_update_parent_status(db: Session, parent_id: int):
return
# Check if all children are done
- all_done = all(child.status == models.TaskStatus.DONE for child in children)
+ all_done = all(child.status == "done" for child in children)
if all_done:
# Mark parent as done
parent = get_task(db, parent_id)
- if parent and parent.status != models.TaskStatus.DONE:
- parent.status = models.TaskStatus.DONE
+ if parent and parent.status != "done":
+ parent.status = "done"
db.commit()
# Recursively check grandparent
@@ -126,12 +136,16 @@ def update_task(
return None
update_data = task.model_dump(exclude_unset=True)
- status_changed = False
- # Check if status is being updated
+ # Validate status against project's statuses if status is being updated
if "status" in update_data:
+ project = get_project(db, db_task.project_id)
+ if project and update_data["status"] not in project.statuses:
+ raise ValueError(f"Invalid status '{update_data['status']}'. Must be one of: {', '.join(project.statuses)}")
status_changed = True
old_status = db_task.status
+ else:
+ status_changed = False
for key, value in update_data.items():
setattr(db_task, key, value)
@@ -140,7 +154,7 @@ def update_task(
db.refresh(db_task)
# If status changed to 'done' and this task has a parent, check if parent should auto-complete
- if status_changed and db_task.status == models.TaskStatus.DONE and db_task.parent_task_id:
+ if status_changed and db_task.status == "done" and db_task.parent_task_id:
check_and_update_parent_status(db, db_task.parent_task_id)
return db_task
@@ -155,8 +169,13 @@ def delete_task(db: Session, task_id: int) -> bool:
return True
-def get_tasks_by_status(db: Session, project_id: int, status: models.TaskStatus) -> List[models.Task]:
+def get_tasks_by_status(db: Session, project_id: int, status: str) -> List[models.Task]:
"""Get all tasks for a project with a specific status"""
+ # Validate status against project's statuses
+ project = get_project(db, project_id)
+ if project and status not in project.statuses:
+ raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(project.statuses)}")
+
return db.query(models.Task).filter(
models.Task.project_id == project_id,
models.Task.status == status
diff --git a/backend/app/main.py b/backend/app/main.py
index 6c5bf9e..4505401 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -98,13 +98,16 @@ def get_project_task_tree(project_id: int, db: Session = Depends(get_db)):
@app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task])
def get_tasks_by_status(
project_id: int,
- status: models.TaskStatus,
+ status: str,
db: Session = Depends(get_db)
):
"""Get all tasks for a project filtered by status (for Kanban view)"""
if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
- return crud.get_tasks_by_status(db, project_id, status)
+ try:
+ return crud.get_tasks_by_status(db, project_id, status)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/tasks", response_model=schemas.Task, status_code=201)
@@ -116,7 +119,10 @@ def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
if task.parent_task_id and not crud.get_task(db, task.parent_task_id):
raise HTTPException(status_code=404, detail="Parent task not found")
- return crud.create_task(db, task)
+ try:
+ return crud.create_task(db, task)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/tasks/{task_id}", response_model=schemas.Task)
@@ -131,10 +137,13 @@ def get_task(task_id: int, db: Session = Depends(get_db)):
@app.put("/api/tasks/{task_id}", response_model=schemas.Task)
def update_task(task_id: int, task: schemas.TaskUpdate, db: Session = Depends(get_db)):
"""Update a task"""
- db_task = crud.update_task(db, task_id, task)
- if not db_task:
- raise HTTPException(status_code=404, detail="Task not found")
- return db_task
+ try:
+ db_task = crud.update_task(db, task_id, task)
+ if not db_task:
+ raise HTTPException(status_code=404, detail="Task not found")
+ return db_task
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
@app.delete("/api/tasks/{task_id}", status_code=204)
@@ -188,6 +197,27 @@ def search_tasks(
# ========== JSON IMPORT ENDPOINT ==========
+def _validate_task_statuses_recursive(
+ tasks: List[schemas.ImportSubtask],
+ valid_statuses: List[str],
+ path: str = ""
+) -> None:
+ """Recursively validate all task statuses against the project's valid statuses"""
+ for idx, task_data in enumerate(tasks):
+ task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]"
+ if task_data.status not in valid_statuses:
+ raise ValueError(
+ f"Invalid status '{task_data.status}' at {task_path}. "
+ f"Must be one of: {', '.join(valid_statuses)}"
+ )
+ if task_data.subtasks:
+ _validate_task_statuses_recursive(
+ task_data.subtasks,
+ valid_statuses,
+ f"{task_path}.subtasks"
+ )
+
+
def _import_tasks_recursive(
db: Session,
project_id: int,
@@ -228,7 +258,8 @@ def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_
{
"project": {
"name": "Project Name",
- "description": "Optional description"
+ "description": "Optional description",
+ "statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional
},
"tasks": [
{
@@ -246,15 +277,26 @@ def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_
]
}
"""
- # Create the project
+ # Create the project with optional statuses
project = crud.create_project(
db,
schemas.ProjectCreate(
name=import_data.project.name,
- description=import_data.project.description
+ description=import_data.project.description,
+ statuses=import_data.project.statuses
)
)
+ # Validate all task statuses before importing
+ if import_data.tasks:
+ try:
+ _validate_task_statuses_recursive(import_data.tasks, project.statuses)
+ except ValueError as e:
+ # Rollback the project creation if validation fails
+ db.delete(project)
+ db.commit()
+ raise HTTPException(status_code=400, detail=str(e))
+
# Recursively import tasks
tasks_created = _import_tasks_recursive(
db, project.id, import_data.tasks
diff --git a/backend/app/models.py b/backend/app/models.py
index 47455c5..d7eb379 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -1,15 +1,11 @@
-from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, JSON
+from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON
from sqlalchemy.orm import relationship
from datetime import datetime
-import enum
from .database import Base
-class TaskStatus(str, enum.Enum):
- BACKLOG = "backlog"
- IN_PROGRESS = "in_progress"
- BLOCKED = "blocked"
- DONE = "done"
+# Default statuses for new projects
+DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
class Project(Base):
@@ -18,6 +14,7 @@ class Project(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
+ statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -32,7 +29,7 @@ class Task(Base):
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
title = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
- status = Column(Enum(TaskStatus), default=TaskStatus.BACKLOG, nullable=False)
+ status = Column(String(50), default="backlog", nullable=False)
sort_order = Column(Integer, default=0)
estimated_minutes = Column(Integer, nullable=True)
tags = Column(JSON, nullable=True)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 00aa35d..1fce06f 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -1,14 +1,14 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from datetime import datetime
-from .models import TaskStatus
+from .models import DEFAULT_STATUSES
# Task Schemas
class TaskBase(BaseModel):
title: str
description: Optional[str] = None
- status: TaskStatus = TaskStatus.BACKLOG
+ status: str = "backlog"
parent_task_id: Optional[int] = None
sort_order: int = 0
estimated_minutes: Optional[int] = None
@@ -23,7 +23,7 @@ class TaskCreate(TaskBase):
class TaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
- status: Optional[TaskStatus] = None
+ status: Optional[str] = None
parent_task_id: Optional[int] = None
sort_order: Optional[int] = None
estimated_minutes: Optional[int] = None
@@ -53,16 +53,18 @@ class ProjectBase(BaseModel):
class ProjectCreate(ProjectBase):
- pass
+ statuses: Optional[List[str]] = None
class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
+ statuses: Optional[List[str]] = None
class Project(ProjectBase):
id: int
+ statuses: List[str]
created_at: datetime
updated_at: datetime
@@ -79,7 +81,7 @@ class ProjectWithTasks(Project):
class ImportSubtask(BaseModel):
title: str
description: Optional[str] = None
- status: TaskStatus = TaskStatus.BACKLOG
+ status: str = "backlog"
estimated_minutes: Optional[int] = None
tags: Optional[List[str]] = None
flag_color: Optional[str] = None
@@ -89,6 +91,7 @@ class ImportSubtask(BaseModel):
class ImportProject(BaseModel):
name: str
description: Optional[str] = None
+ statuses: Optional[List[str]] = None
class ImportData(BaseModel):
diff --git a/backend/migrate_add_statuses.py b/backend/migrate_add_statuses.py
new file mode 100644
index 0000000..8be7a37
--- /dev/null
+++ b/backend/migrate_add_statuses.py
@@ -0,0 +1,62 @@
+"""
+Migration script to add statuses column to projects table
+Run this script once to update existing database
+"""
+import sqlite3
+import json
+
+# Default statuses for existing projects
+DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
+
+def migrate():
+ # Connect to the database
+ conn = sqlite3.connect('tesseract.db')
+ cursor = conn.cursor()
+
+ try:
+ # Check if statuses column already exists
+ cursor.execute("PRAGMA table_info(projects)")
+ columns = [column[1] for column in cursor.fetchall()]
+
+ if 'statuses' in columns:
+ print("✓ Column 'statuses' already exists in projects table")
+ return
+
+ # Add statuses column with default value
+ print("Adding 'statuses' column to projects table...")
+ cursor.execute("""
+ ALTER TABLE projects
+ ADD COLUMN statuses TEXT NOT NULL DEFAULT ?
+ """, (json.dumps(DEFAULT_STATUSES),))
+
+ # Update all existing projects with default statuses
+ cursor.execute("""
+ UPDATE projects
+ SET statuses = ?
+ WHERE statuses IS NULL OR statuses = ''
+ """, (json.dumps(DEFAULT_STATUSES),))
+
+ conn.commit()
+ print("✓ Successfully added 'statuses' column to projects table")
+ print(f"✓ Set default statuses for all existing projects: {DEFAULT_STATUSES}")
+
+ # Show count of updated projects
+ cursor.execute("SELECT COUNT(*) FROM projects")
+ count = cursor.fetchone()[0]
+ print(f"✓ Updated {count} project(s)")
+
+ except sqlite3.Error as e:
+ print(f"✗ Error during migration: {e}")
+ conn.rollback()
+ raise
+ finally:
+ conn.close()
+
+if __name__ == "__main__":
+ print("=" * 60)
+ print("Database Migration: Add statuses column to projects")
+ print("=" * 60)
+ migrate()
+ print("=" * 60)
+ print("Migration completed!")
+ print("=" * 60)
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 9c77261..da851da 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -13,7 +13,7 @@ function App() {
TESSERACT
Task Decomposition Engine
- v{import.meta.env.VITE_APP_VERSION || '0.1.5'}
+ v{import.meta.env.VITE_APP_VERSION || '0.1.6'}
diff --git a/frontend/src/components/KanbanView.jsx b/frontend/src/components/KanbanView.jsx
index e9c1ffe..e1504cd 100644
--- a/frontend/src/components/KanbanView.jsx
+++ b/frontend/src/components/KanbanView.jsx
@@ -10,12 +10,21 @@ 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(' ')
+}
+
+// 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',
@@ -60,9 +69,10 @@ function hasDescendantsInStatus(taskId, allTasks, status) {
return getDescendantsInStatus(taskId, allTasks, status).length > 0
}
-function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards }) {
+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)
// Use global expanded state
const isExpanded = expandedCards[task.id] || false
@@ -93,6 +103,26 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
}
}
+ 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
@@ -204,11 +234,19 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
+
setShowAddSubtask(true)}
+ className="text-cyber-orange hover:text-cyber-orange-bright p-1"
+ title="Add subtask"
+ >
+
+
setIsEditing(true)}
+ projectStatuses={projectStatuses}
/>
@@ -216,6 +254,19 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
)}
+ {/* Add Subtask Form */}
+ {showAddSubtask && (
+
+ setShowAddSubtask(false)}
+ submitLabel="Add Subtask"
+ projectStatuses={projectStatuses}
+ defaultStatus={columnStatus}
+ />
+
+ )}
+
{/* Expanded children */}
{isParent && isExpanded && childrenInColumn.length > 0 && (
@@ -230,6 +281,8 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
columnStatus={columnStatus}
expandedCards={expandedCards}
setExpandedCards={setExpandedCards}
+ projectStatuses={projectStatuses}
+ projectId={projectId}
/>
))}
@@ -238,7 +291,7 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
)
}
-function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards }) {
+function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards, projectStatuses }) {
const [showAddTask, setShowAddTask] = useState(false)
const handleAddTask = async (taskData) => {
@@ -248,7 +301,7 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
parent_task_id: null,
title: taskData.title,
description: taskData.description,
- status: status.key,
+ status: taskData.status,
tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color
@@ -306,6 +359,8 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
onSubmit={handleAddTask}
onCancel={() => setShowAddTask(false)}
submitLabel="Add Task"
+ projectStatuses={projectStatuses}
+ defaultStatus={status.key}
/>
)}
@@ -327,6 +382,8 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
columnStatus={status.key}
expandedCards={expandedCards}
setExpandedCards={setExpandedCards}
+ projectStatuses={projectStatuses}
+ projectId={projectId}
/>
)
})}
@@ -335,12 +392,20 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
)
}
-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()
}, [projectId])
@@ -430,7 +495,7 @@ function KanbanView({ projectId }) {
- {STATUSES.map(status => (
+ {statusesWithMeta.map(status => (
))}
diff --git a/frontend/src/components/ProjectSettings.jsx b/frontend/src/components/ProjectSettings.jsx
new file mode 100644
index 0000000..ac03370
--- /dev/null
+++ b/frontend/src/components/ProjectSettings.jsx
@@ -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 (
+
+
+ {/* Header */}
+
+
Project Settings
+
+
+
+
+
+ {/* Content */}
+
+
+
Project Details
+
+
+ Name:
+ {project.name}
+
+ {project.description && (
+
+ Description:
+ {project.description}
+
+ )}
+
+
+
+
+
Status Workflow
+
+ Drag to reorder, click to rename. Tasks will appear in Kanban columns in this order.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {statuses.map((status, index) => (
+
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 && (
+
+ )}
+
+ {editingIndex === index ? (
+ <>
+ 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
+ />
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+ 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(' ')}
+
+
+ {taskCounts[status] || 0} task{taskCounts[status] === 1 ? '' : 's'}
+
+ handleDeleteStatus(index)}
+ className="text-gray-400 hover:text-red-400"
+ disabled={statuses.length === 1}
+ >
+
+
+ >
+ )}
+
+ ))}
+
+
+
+
+ Add Status
+
+
+
+
+ {/* Footer */}
+
+
+ Cancel
+
+
+ Save Changes
+
+
+
+ {/* Delete Warning Dialog */}
+ {deleteWarning && (
+
+
+
+
+
+
Cannot Delete Status
+
+ The status "{deleteWarning.status}" has {deleteWarning.count} task{deleteWarning.count === 1 ? '' : 's'}.
+ Please move or delete those tasks first.
+
+
+
+
+ setDeleteWarning(null)}
+ className="px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold"
+ >
+ OK
+
+
+
+
+ )}
+
+
+ )
+}
+
+export default ProjectSettings
diff --git a/frontend/src/components/TaskForm.jsx b/frontend/src/components/TaskForm.jsx
index 049d893..1efb441 100644
--- a/frontend/src/components/TaskForm.jsx
+++ b/frontend/src/components/TaskForm.jsx
@@ -12,13 +12,22 @@ const FLAG_COLORS = [
{ name: 'pink', label: 'Pink', color: 'bg-pink-500' }
]
-function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
+// 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()
@@ -37,7 +46,8 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
description: description.trim() || null,
tags: tagList && tagList.length > 0 ? tagList : null,
estimated_minutes: totalMinutes > 0 ? totalMinutes : null,
- flag_color: flagColor
+ flag_color: flagColor,
+ status: status
}
onSubmit(taskData)
@@ -110,6 +120,22 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
+ {/* Status */}
+
+ Status
+ 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) => (
+
+ {formatStatusLabel(s)}
+
+ ))}
+
+
+
{/* Flag Color */}
Flag Color
diff --git a/frontend/src/components/TaskMenu.jsx b/frontend/src/components/TaskMenu.jsx
index 9afd65e..ad5a906 100644
--- a/frontend/src/components/TaskMenu.jsx
+++ b/frontend/src/components/TaskMenu.jsx
@@ -12,14 +12,23 @@ const FLAG_COLORS = [
{ name: 'pink', color: 'bg-pink-500' }
]
-const STATUSES = [
- { key: 'backlog', label: 'Backlog', color: 'text-gray-400' },
- { key: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
- { key: 'blocked', label: 'Blocked', color: 'text-red-400' },
- { key: 'done', label: 'Done', color: 'text-green-400' }
-]
+// Helper to format status label
+const formatStatusLabel = (status) => {
+ return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
+}
-function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
+// 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)
@@ -334,17 +343,17 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
Change Status
- {STATUSES.map(({ key, label, color }) => (
+ {(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map((status) => (
handleUpdateStatus(key)}
+ key={status}
+ onClick={() => handleUpdateStatus(status)}
className={`w-full text-left px-2 py-1.5 rounded text-sm ${
- task.status === key
+ task.status === status
? 'bg-cyber-orange/20 border border-cyber-orange/40'
: 'hover:bg-cyber-darker border border-transparent'
- } ${color} transition-all`}
+ } ${getStatusTextColor(status)} transition-all`}
>
- {label} {task.status === key && '✓'}
+ {formatStatusLabel(status)} {task.status === status && '✓'}
))}
diff --git a/frontend/src/components/TreeView.jsx b/frontend/src/components/TreeView.jsx
index 3274378..2b608a5 100644
--- a/frontend/src/components/TreeView.jsx
+++ b/frontend/src/components/TreeView.jsx
@@ -18,18 +18,20 @@ 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
}
const FLAG_COLORS = {
@@ -42,7 +44,7 @@ const FLAG_COLORS = {
pink: 'bg-pink-500'
}
-function TaskNode({ task, projectId, onUpdate, level = 0 }) {
+function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
const [isExpanded, setIsExpanded] = useState(true)
const [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(task.title)
@@ -81,7 +83,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
parent_task_id: task.id,
title: taskData.title,
description: taskData.description,
- status: 'backlog',
+ status: taskData.status,
tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color
@@ -126,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"
>
- Backlog
- In Progress
- Blocked
- Done
+ {(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map(status => (
+ {formatStatusLabel(status)}
+ ))}
)}
{task.title}
-
- {STATUS_LABELS[task.status]}
+
+ {formatStatusLabel(task.status)}
@@ -211,6 +212,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
onUpdate={onUpdate}
onDelete={handleDelete}
onEdit={() => setIsEditing(true)}
+ projectStatuses={projectStatuses}
/>
>
@@ -224,6 +226,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
onSubmit={handleAddSubtask}
onCancel={() => setShowAddSubtask(false)}
submitLabel="Add Subtask"
+ projectStatuses={projectStatuses}
/>
)}
@@ -238,6 +241,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
projectId={projectId}
onUpdate={onUpdate}
level={level + 1}
+ projectStatuses={projectStatuses}
/>
))}
@@ -246,7 +250,8 @@ 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('')
@@ -275,7 +280,7 @@ function TreeView({ projectId }) {
parent_task_id: null,
title: taskData.title,
description: taskData.description,
- status: 'backlog',
+ status: taskData.status,
tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color
@@ -314,6 +319,7 @@ function TreeView({ projectId }) {
onSubmit={handleAddRootTask}
onCancel={() => setShowAddRoot(false)}
submitLabel="Add Task"
+ projectStatuses={projectStatuses}
/>
)}
@@ -331,6 +337,7 @@ function TreeView({ projectId }) {
task={task}
projectId={projectId}
onUpdate={loadTasks}
+ projectStatuses={projectStatuses}
/>
))}
diff --git a/frontend/src/pages/ProjectView.jsx b/frontend/src/pages/ProjectView.jsx
index c1ce7fc..630d8f8 100644
--- a/frontend/src/pages/ProjectView.jsx
+++ b/frontend/src/pages/ProjectView.jsx
@@ -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() {
)}
-
+
+
+ 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'
+ }`}
+ >
+
+ Tree View
+
+ 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'
+ }`}
+ >
+
+ Kanban
+
+
+
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"
>
-
- Tree View
-
-
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'
- }`}
- >
-
- Kanban
+
{view === 'tree' ? (
-
+
) : (
-
+
+ )}
+
+ {showSettings && (
+ setShowSettings(false)}
+ onUpdate={loadProject}
+ />
)}
)