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
+ 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 + /> + + + + ) : ( + <> + + + {taskCounts[status] || 0} task{taskCounts[status] === 1 ? '' : 's'} + + + + )} +
+ ))} +
+ + +
+
+ + {/* Footer */} +
+ + +
+ + {/* 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. +

+
+
+
+ +
+
+
+ )} +
+
+ ) +} + +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 */} +
+ + +
+ {/* 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) => ( ))}
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" > - - - - + {(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map(status => ( + + ))} + + + - {view === 'tree' ? ( - + ) : ( - + + )} + + {showSettings && ( + setShowSettings(false)} + onUpdate={loadProject} + /> )} )