v0.1.6 changes
This commit is contained in:
@@ -5,7 +5,12 @@ from . import models, schemas
|
|||||||
|
|
||||||
# Project CRUD
|
# Project CRUD
|
||||||
def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project:
|
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.add(db_project)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_project)
|
db.refresh(db_project)
|
||||||
@@ -47,6 +52,11 @@ def delete_project(db: Session, project_id: int) -> bool:
|
|||||||
|
|
||||||
# Task CRUD
|
# Task CRUD
|
||||||
def create_task(db: Session, task: schemas.TaskCreate) -> models.Task:
|
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
|
# Get max sort_order for siblings
|
||||||
if task.parent_task_id:
|
if task.parent_task_id:
|
||||||
max_order = db.query(models.Task).filter(
|
max_order = db.query(models.Task).filter(
|
||||||
@@ -104,13 +114,13 @@ def check_and_update_parent_status(db: Session, parent_id: int):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Check if all children are done
|
# 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:
|
if all_done:
|
||||||
# Mark parent as done
|
# Mark parent as done
|
||||||
parent = get_task(db, parent_id)
|
parent = get_task(db, parent_id)
|
||||||
if parent and parent.status != models.TaskStatus.DONE:
|
if parent and parent.status != "done":
|
||||||
parent.status = models.TaskStatus.DONE
|
parent.status = "done"
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Recursively check grandparent
|
# Recursively check grandparent
|
||||||
@@ -126,12 +136,16 @@ def update_task(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
update_data = task.model_dump(exclude_unset=True)
|
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:
|
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
|
status_changed = True
|
||||||
old_status = db_task.status
|
old_status = db_task.status
|
||||||
|
else:
|
||||||
|
status_changed = False
|
||||||
|
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(db_task, key, value)
|
setattr(db_task, key, value)
|
||||||
@@ -140,7 +154,7 @@ def update_task(
|
|||||||
db.refresh(db_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 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)
|
check_and_update_parent_status(db, db_task.parent_task_id)
|
||||||
|
|
||||||
return db_task
|
return db_task
|
||||||
@@ -155,8 +169,13 @@ def delete_task(db: Session, task_id: int) -> bool:
|
|||||||
return True
|
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"""
|
"""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(
|
return db.query(models.Task).filter(
|
||||||
models.Task.project_id == project_id,
|
models.Task.project_id == project_id,
|
||||||
models.Task.status == status
|
models.Task.status == status
|
||||||
|
|||||||
@@ -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])
|
@app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task])
|
||||||
def get_tasks_by_status(
|
def get_tasks_by_status(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
status: models.TaskStatus,
|
status: str,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get all tasks for a project filtered by status (for Kanban view)"""
|
"""Get all tasks for a project filtered by status (for Kanban view)"""
|
||||||
if not crud.get_project(db, project_id):
|
if not crud.get_project(db, project_id):
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
try:
|
||||||
return crud.get_tasks_by_status(db, project_id, status)
|
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)
|
@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):
|
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")
|
raise HTTPException(status_code=404, detail="Parent task not found")
|
||||||
|
|
||||||
|
try:
|
||||||
return crud.create_task(db, task)
|
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)
|
@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)
|
@app.put("/api/tasks/{task_id}", response_model=schemas.Task)
|
||||||
def update_task(task_id: int, task: schemas.TaskUpdate, db: Session = Depends(get_db)):
|
def update_task(task_id: int, task: schemas.TaskUpdate, db: Session = Depends(get_db)):
|
||||||
"""Update a task"""
|
"""Update a task"""
|
||||||
|
try:
|
||||||
db_task = crud.update_task(db, task_id, task)
|
db_task = crud.update_task(db, task_id, task)
|
||||||
if not db_task:
|
if not db_task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
return db_task
|
return db_task
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/tasks/{task_id}", status_code=204)
|
@app.delete("/api/tasks/{task_id}", status_code=204)
|
||||||
@@ -188,6 +197,27 @@ def search_tasks(
|
|||||||
|
|
||||||
# ========== JSON IMPORT ENDPOINT ==========
|
# ========== 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(
|
def _import_tasks_recursive(
|
||||||
db: Session,
|
db: Session,
|
||||||
project_id: int,
|
project_id: int,
|
||||||
@@ -228,7 +258,8 @@ def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_
|
|||||||
{
|
{
|
||||||
"project": {
|
"project": {
|
||||||
"name": "Project Name",
|
"name": "Project Name",
|
||||||
"description": "Optional description"
|
"description": "Optional description",
|
||||||
|
"statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional
|
||||||
},
|
},
|
||||||
"tasks": [
|
"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(
|
project = crud.create_project(
|
||||||
db,
|
db,
|
||||||
schemas.ProjectCreate(
|
schemas.ProjectCreate(
|
||||||
name=import_data.project.name,
|
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
|
# Recursively import tasks
|
||||||
tasks_created = _import_tasks_recursive(
|
tasks_created = _import_tasks_recursive(
|
||||||
db, project.id, import_data.tasks
|
db, project.id, import_data.tasks
|
||||||
|
|||||||
@@ -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 sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import enum
|
|
||||||
from .database import Base
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
class TaskStatus(str, enum.Enum):
|
# Default statuses for new projects
|
||||||
BACKLOG = "backlog"
|
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
|
||||||
IN_PROGRESS = "in_progress"
|
|
||||||
BLOCKED = "blocked"
|
|
||||||
DONE = "done"
|
|
||||||
|
|
||||||
|
|
||||||
class Project(Base):
|
class Project(Base):
|
||||||
@@ -18,6 +14,7 @@ class Project(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
name = Column(String(255), nullable=False)
|
name = Column(String(255), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
|
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=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)
|
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
|
||||||
title = Column(String(500), nullable=False)
|
title = Column(String(500), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
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)
|
sort_order = Column(Integer, default=0)
|
||||||
estimated_minutes = Column(Integer, nullable=True)
|
estimated_minutes = Column(Integer, nullable=True)
|
||||||
tags = Column(JSON, nullable=True)
|
tags = Column(JSON, nullable=True)
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from .models import TaskStatus
|
from .models import DEFAULT_STATUSES
|
||||||
|
|
||||||
|
|
||||||
# Task Schemas
|
# Task Schemas
|
||||||
class TaskBase(BaseModel):
|
class TaskBase(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
status: TaskStatus = TaskStatus.BACKLOG
|
status: str = "backlog"
|
||||||
parent_task_id: Optional[int] = None
|
parent_task_id: Optional[int] = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
estimated_minutes: Optional[int] = None
|
estimated_minutes: Optional[int] = None
|
||||||
@@ -23,7 +23,7 @@ class TaskCreate(TaskBase):
|
|||||||
class TaskUpdate(BaseModel):
|
class TaskUpdate(BaseModel):
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
status: Optional[TaskStatus] = None
|
status: Optional[str] = None
|
||||||
parent_task_id: Optional[int] = None
|
parent_task_id: Optional[int] = None
|
||||||
sort_order: Optional[int] = None
|
sort_order: Optional[int] = None
|
||||||
estimated_minutes: Optional[int] = None
|
estimated_minutes: Optional[int] = None
|
||||||
@@ -53,16 +53,18 @@ class ProjectBase(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ProjectCreate(ProjectBase):
|
class ProjectCreate(ProjectBase):
|
||||||
pass
|
statuses: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(BaseModel):
|
class ProjectUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
statuses: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class Project(ProjectBase):
|
class Project(ProjectBase):
|
||||||
id: int
|
id: int
|
||||||
|
statuses: List[str]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -79,7 +81,7 @@ class ProjectWithTasks(Project):
|
|||||||
class ImportSubtask(BaseModel):
|
class ImportSubtask(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
status: TaskStatus = TaskStatus.BACKLOG
|
status: str = "backlog"
|
||||||
estimated_minutes: Optional[int] = None
|
estimated_minutes: Optional[int] = None
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
flag_color: Optional[str] = None
|
flag_color: Optional[str] = None
|
||||||
@@ -89,6 +91,7 @@ class ImportSubtask(BaseModel):
|
|||||||
class ImportProject(BaseModel):
|
class ImportProject(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
statuses: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class ImportData(BaseModel):
|
class ImportData(BaseModel):
|
||||||
|
|||||||
62
backend/migrate_add_statuses.py
Normal file
62
backend/migrate_add_statuses.py
Normal file
@@ -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)
|
||||||
@@ -13,7 +13,7 @@ function App() {
|
|||||||
<h1 className="text-2xl font-bold text-cyber-orange">
|
<h1 className="text-2xl font-bold text-cyber-orange">
|
||||||
TESSERACT
|
TESSERACT
|
||||||
<span className="ml-3 text-sm text-gray-500">Task Decomposition Engine</span>
|
<span className="ml-3 text-sm text-gray-500">Task Decomposition Engine</span>
|
||||||
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.5'}</span>
|
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
|
|||||||
@@ -10,12 +10,21 @@ import { formatTimeWithTotal } from '../utils/format'
|
|||||||
import TaskMenu from './TaskMenu'
|
import TaskMenu from './TaskMenu'
|
||||||
import TaskForm from './TaskForm'
|
import TaskForm from './TaskForm'
|
||||||
|
|
||||||
const STATUSES = [
|
// Helper to format status label
|
||||||
{ key: 'backlog', label: 'Backlog', color: 'border-gray-600' },
|
const formatStatusLabel = (status) => {
|
||||||
{ key: 'in_progress', label: 'In Progress', color: 'border-blue-500' },
|
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||||
{ key: 'blocked', label: 'Blocked', color: 'border-red-500' },
|
}
|
||||||
{ key: 'done', label: 'Done', color: 'border-green-500' }
|
|
||||||
]
|
// 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 = {
|
const FLAG_COLORS = {
|
||||||
red: 'bg-red-500',
|
red: 'bg-red-500',
|
||||||
@@ -60,9 +69,10 @@ function hasDescendantsInStatus(taskId, allTasks, status) {
|
|||||||
return getDescendantsInStatus(taskId, allTasks, status).length > 0
|
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 [isEditing, setIsEditing] = useState(false)
|
||||||
const [editTitle, setEditTitle] = useState(task.title)
|
const [editTitle, setEditTitle] = useState(task.title)
|
||||||
|
const [showAddSubtask, setShowAddSubtask] = useState(false)
|
||||||
|
|
||||||
// Use global expanded state
|
// Use global expanded state
|
||||||
const isExpanded = expandedCards[task.id] || false
|
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
|
// For parent cards, get children in this column's status
|
||||||
const childrenInColumn = isParent ? getDescendantsInStatus(task.id, allTasks, columnStatus) : []
|
const childrenInColumn = isParent ? getDescendantsInStatus(task.id, allTasks, columnStatus) : []
|
||||||
const totalChildren = isParent ? allTasks.filter(t => t.parent_task_id === task.id).length : 0
|
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
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddSubtask(true)}
|
||||||
|
className="text-cyber-orange hover:text-cyber-orange-bright p-1"
|
||||||
|
title="Add subtask"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
<TaskMenu
|
<TaskMenu
|
||||||
task={task}
|
task={task}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onEdit={() => setIsEditing(true)}
|
onEdit={() => setIsEditing(true)}
|
||||||
|
projectStatuses={projectStatuses}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,6 +254,19 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
|
|||||||
)}
|
)}
|
||||||
</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 */}
|
{/* Expanded children */}
|
||||||
{isParent && isExpanded && childrenInColumn.length > 0 && (
|
{isParent && isExpanded && childrenInColumn.length > 0 && (
|
||||||
<div className="ml-6 mt-2 space-y-2">
|
<div className="ml-6 mt-2 space-y-2">
|
||||||
@@ -230,6 +281,8 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
|
|||||||
columnStatus={columnStatus}
|
columnStatus={columnStatus}
|
||||||
expandedCards={expandedCards}
|
expandedCards={expandedCards}
|
||||||
setExpandedCards={setExpandedCards}
|
setExpandedCards={setExpandedCards}
|
||||||
|
projectStatuses={projectStatuses}
|
||||||
|
projectId={projectId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -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 [showAddTask, setShowAddTask] = useState(false)
|
||||||
|
|
||||||
const handleAddTask = async (taskData) => {
|
const handleAddTask = async (taskData) => {
|
||||||
@@ -248,7 +301,7 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
|
|||||||
parent_task_id: null,
|
parent_task_id: null,
|
||||||
title: taskData.title,
|
title: taskData.title,
|
||||||
description: taskData.description,
|
description: taskData.description,
|
||||||
status: status.key,
|
status: taskData.status,
|
||||||
tags: taskData.tags,
|
tags: taskData.tags,
|
||||||
estimated_minutes: taskData.estimated_minutes,
|
estimated_minutes: taskData.estimated_minutes,
|
||||||
flag_color: taskData.flag_color
|
flag_color: taskData.flag_color
|
||||||
@@ -306,6 +359,8 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
|
|||||||
onSubmit={handleAddTask}
|
onSubmit={handleAddTask}
|
||||||
onCancel={() => setShowAddTask(false)}
|
onCancel={() => setShowAddTask(false)}
|
||||||
submitLabel="Add Task"
|
submitLabel="Add Task"
|
||||||
|
projectStatuses={projectStatuses}
|
||||||
|
defaultStatus={status.key}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -327,6 +382,8 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
|
|||||||
columnStatus={status.key}
|
columnStatus={status.key}
|
||||||
expandedCards={expandedCards}
|
expandedCards={expandedCards}
|
||||||
setExpandedCards={setExpandedCards}
|
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 [allTasks, setAllTasks] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [expandedCards, setExpandedCards] = 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(() => {
|
useEffect(() => {
|
||||||
loadTasks()
|
loadTasks()
|
||||||
}, [projectId])
|
}, [projectId])
|
||||||
@@ -430,7 +495,7 @@ function KanbanView({ projectId }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
{STATUSES.map(status => (
|
{statusesWithMeta.map(status => (
|
||||||
<KanbanColumn
|
<KanbanColumn
|
||||||
key={status.key}
|
key={status.key}
|
||||||
status={status}
|
status={status}
|
||||||
@@ -441,6 +506,7 @@ function KanbanView({ projectId }) {
|
|||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
expandedCards={expandedCards}
|
expandedCards={expandedCards}
|
||||||
setExpandedCards={setExpandedCards}
|
setExpandedCards={setExpandedCards}
|
||||||
|
projectStatuses={statuses}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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
|
||||||
@@ -12,13 +12,22 @@ const FLAG_COLORS = [
|
|||||||
{ name: 'pink', label: 'Pink', color: 'bg-pink-500' }
|
{ 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 [title, setTitle] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [tags, setTags] = useState('')
|
const [tags, setTags] = useState('')
|
||||||
const [hours, setHours] = useState('')
|
const [hours, setHours] = useState('')
|
||||||
const [minutes, setMinutes] = useState('')
|
const [minutes, setMinutes] = useState('')
|
||||||
const [flagColor, setFlagColor] = useState(null)
|
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) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -37,7 +46,8 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
|
|||||||
description: description.trim() || null,
|
description: description.trim() || null,
|
||||||
tags: tagList && tagList.length > 0 ? tagList : null,
|
tags: tagList && tagList.length > 0 ? tagList : null,
|
||||||
estimated_minutes: totalMinutes > 0 ? totalMinutes : null,
|
estimated_minutes: totalMinutes > 0 ? totalMinutes : null,
|
||||||
flag_color: flagColor
|
flag_color: flagColor,
|
||||||
|
status: status
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit(taskData)
|
onSubmit(taskData)
|
||||||
@@ -110,6 +120,22 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
|
|||||||
</div>
|
</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 */}
|
{/* Flag Color */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1">Flag Color</label>
|
<label className="block text-xs text-gray-400 mb-1">Flag Color</label>
|
||||||
|
|||||||
@@ -12,14 +12,23 @@ const FLAG_COLORS = [
|
|||||||
{ name: 'pink', color: 'bg-pink-500' }
|
{ name: 'pink', color: 'bg-pink-500' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const STATUSES = [
|
// Helper to format status label
|
||||||
{ key: 'backlog', label: 'Backlog', color: 'text-gray-400' },
|
const formatStatusLabel = (status) => {
|
||||||
{ key: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
|
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||||
{ key: 'blocked', label: 'Blocked', color: 'text-red-400' },
|
}
|
||||||
{ key: 'done', label: 'Done', color: 'text-green-400' }
|
|
||||||
]
|
|
||||||
|
|
||||||
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 [isOpen, setIsOpen] = useState(false)
|
||||||
const [showTimeEdit, setShowTimeEdit] = useState(false)
|
const [showTimeEdit, setShowTimeEdit] = useState(false)
|
||||||
const [showDescriptionEdit, setShowDescriptionEdit] = useState(false)
|
const [showDescriptionEdit, setShowDescriptionEdit] = useState(false)
|
||||||
@@ -334,17 +343,17 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
|
|||||||
<span className="text-sm text-gray-300">Change Status</span>
|
<span className="text-sm text-gray-300">Change Status</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{STATUSES.map(({ key, label, color }) => (
|
{(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map((status) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={status}
|
||||||
onClick={() => handleUpdateStatus(key)}
|
onClick={() => handleUpdateStatus(status)}
|
||||||
className={`w-full text-left px-2 py-1.5 rounded text-sm ${
|
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'
|
? 'bg-cyber-orange/20 border border-cyber-orange/40'
|
||||||
: 'hover:bg-cyber-darker border border-transparent'
|
: 'hover:bg-cyber-darker border border-transparent'
|
||||||
} ${color} transition-all`}
|
} ${getStatusTextColor(status)} transition-all`}
|
||||||
>
|
>
|
||||||
{label} {task.status === key && '✓'}
|
{formatStatusLabel(status)} {task.status === status && '✓'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,18 +18,20 @@ import { formatTimeWithTotal } from '../utils/format'
|
|||||||
import TaskMenu from './TaskMenu'
|
import TaskMenu from './TaskMenu'
|
||||||
import TaskForm from './TaskForm'
|
import TaskForm from './TaskForm'
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
// Helper to format status label
|
||||||
backlog: 'text-gray-400',
|
const formatStatusLabel = (status) => {
|
||||||
in_progress: 'text-blue-400',
|
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||||
blocked: 'text-red-400',
|
|
||||||
done: 'text-green-400'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
// Helper to get status color
|
||||||
backlog: 'Backlog',
|
const getStatusColor = (status) => {
|
||||||
in_progress: 'In Progress',
|
const lowerStatus = status.toLowerCase()
|
||||||
blocked: 'Blocked',
|
if (lowerStatus === 'backlog') return 'text-gray-400'
|
||||||
done: 'Done'
|
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 = {
|
const FLAG_COLORS = {
|
||||||
@@ -42,7 +44,7 @@ const FLAG_COLORS = {
|
|||||||
pink: 'bg-pink-500'
|
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 [isExpanded, setIsExpanded] = useState(true)
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [editTitle, setEditTitle] = useState(task.title)
|
const [editTitle, setEditTitle] = useState(task.title)
|
||||||
@@ -81,7 +83,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
parent_task_id: task.id,
|
parent_task_id: task.id,
|
||||||
title: taskData.title,
|
title: taskData.title,
|
||||||
description: taskData.description,
|
description: taskData.description,
|
||||||
status: 'backlog',
|
status: taskData.status,
|
||||||
tags: taskData.tags,
|
tags: taskData.tags,
|
||||||
estimated_minutes: taskData.estimated_minutes,
|
estimated_minutes: taskData.estimated_minutes,
|
||||||
flag_color: taskData.flag_color
|
flag_color: taskData.flag_color
|
||||||
@@ -126,10 +128,9 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
onChange={(e) => setEditStatus(e.target.value)}
|
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"
|
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>
|
{(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map(status => (
|
||||||
<option value="in_progress">In Progress</option>
|
<option key={status} value={status}>{formatStatusLabel(status)}</option>
|
||||||
<option value="blocked">Blocked</option>
|
))}
|
||||||
<option value="done">Done</option>
|
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
@@ -157,8 +158,8 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
<Flag size={14} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" />
|
<Flag size={14} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" />
|
||||||
)}
|
)}
|
||||||
<span className="text-gray-200">{task.title}</span>
|
<span className="text-gray-200">{task.title}</span>
|
||||||
<span className={`ml-2 text-xs ${STATUS_COLORS[task.status]}`}>
|
<span className={`ml-2 text-xs ${getStatusColor(task.status)}`}>
|
||||||
{STATUS_LABELS[task.status]}
|
{formatStatusLabel(task.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,6 +212,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onEdit={() => setIsEditing(true)}
|
onEdit={() => setIsEditing(true)}
|
||||||
|
projectStatuses={projectStatuses}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -224,6 +226,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
onSubmit={handleAddSubtask}
|
onSubmit={handleAddSubtask}
|
||||||
onCancel={() => setShowAddSubtask(false)}
|
onCancel={() => setShowAddSubtask(false)}
|
||||||
submitLabel="Add Subtask"
|
submitLabel="Add Subtask"
|
||||||
|
projectStatuses={projectStatuses}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -238,6 +241,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
level={level + 1}
|
level={level + 1}
|
||||||
|
projectStatuses={projectStatuses}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -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 [tasks, setTasks] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -275,7 +280,7 @@ function TreeView({ projectId }) {
|
|||||||
parent_task_id: null,
|
parent_task_id: null,
|
||||||
title: taskData.title,
|
title: taskData.title,
|
||||||
description: taskData.description,
|
description: taskData.description,
|
||||||
status: 'backlog',
|
status: taskData.status,
|
||||||
tags: taskData.tags,
|
tags: taskData.tags,
|
||||||
estimated_minutes: taskData.estimated_minutes,
|
estimated_minutes: taskData.estimated_minutes,
|
||||||
flag_color: taskData.flag_color
|
flag_color: taskData.flag_color
|
||||||
@@ -314,6 +319,7 @@ function TreeView({ projectId }) {
|
|||||||
onSubmit={handleAddRootTask}
|
onSubmit={handleAddRootTask}
|
||||||
onCancel={() => setShowAddRoot(false)}
|
onCancel={() => setShowAddRoot(false)}
|
||||||
submitLabel="Add Task"
|
submitLabel="Add Task"
|
||||||
|
projectStatuses={projectStatuses}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -331,6 +337,7 @@ function TreeView({ projectId }) {
|
|||||||
task={task}
|
task={task}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onUpdate={loadTasks}
|
onUpdate={loadTasks}
|
||||||
|
projectStatuses={projectStatuses}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
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 { getProject } from '../utils/api'
|
||||||
import TreeView from '../components/TreeView'
|
import TreeView from '../components/TreeView'
|
||||||
import KanbanView from '../components/KanbanView'
|
import KanbanView from '../components/KanbanView'
|
||||||
|
import ProjectSettings from '../components/ProjectSettings'
|
||||||
|
|
||||||
function ProjectView() {
|
function ProjectView() {
|
||||||
const { projectId } = useParams()
|
const { projectId } = useParams()
|
||||||
@@ -12,6 +13,7 @@ function ProjectView() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [view, setView] = useState('tree') // 'tree' or 'kanban'
|
const [view, setView] = useState('tree') // 'tree' or 'kanban'
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProject()
|
loadProject()
|
||||||
@@ -65,6 +67,7 @@ function ProjectView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
<div className="flex gap-2 bg-cyber-darkest rounded-lg p-1 border border-cyber-orange/30">
|
<div className="flex gap-2 bg-cyber-darkest rounded-lg p-1 border border-cyber-orange/30">
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('tree')}
|
onClick={() => setView('tree')}
|
||||||
@@ -89,13 +92,30 @@ function ProjectView() {
|
|||||||
Kanban
|
Kanban
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Settings size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === 'tree' ? (
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user