Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5da6e075b4 |
@@ -1,188 +1,283 @@
|
|||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from . import models, schemas
|
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:
|
||||||
project_data = project.model_dump()
|
project_data = project.model_dump()
|
||||||
# Ensure statuses has a default value if not provided
|
# Ensure statuses has a default value if not provided
|
||||||
if project_data.get("statuses") is None:
|
if project_data.get("statuses") is None:
|
||||||
project_data["statuses"] = models.DEFAULT_STATUSES
|
project_data["statuses"] = models.DEFAULT_STATUSES
|
||||||
|
|
||||||
db_project = models.Project(**project_data)
|
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)
|
||||||
return db_project
|
return db_project
|
||||||
|
|
||||||
|
|
||||||
def get_project(db: Session, project_id: int) -> Optional[models.Project]:
|
def get_project(db: Session, project_id: int) -> Optional[models.Project]:
|
||||||
return db.query(models.Project).filter(models.Project.id == project_id).first()
|
return db.query(models.Project).filter(models.Project.id == project_id).first()
|
||||||
|
|
||||||
|
|
||||||
def get_projects(db: Session, skip: int = 0, limit: int = 100, archived: Optional[bool] = None) -> List[models.Project]:
|
def get_projects(db: Session, skip: int = 0, limit: int = 100, archived: Optional[bool] = None) -> List[models.Project]:
|
||||||
query = db.query(models.Project)
|
query = db.query(models.Project)
|
||||||
|
|
||||||
# Filter by archive status if specified
|
# Filter by archive status if specified
|
||||||
if archived is not None:
|
if archived is not None:
|
||||||
query = query.filter(models.Project.is_archived == archived)
|
query = query.filter(models.Project.is_archived == archived)
|
||||||
|
|
||||||
return query.offset(skip).limit(limit).all()
|
return query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
def update_project(
|
def update_project(
|
||||||
db: Session, project_id: int, project: schemas.ProjectUpdate
|
db: Session, project_id: int, project: schemas.ProjectUpdate
|
||||||
) -> Optional[models.Project]:
|
) -> Optional[models.Project]:
|
||||||
db_project = get_project(db, project_id)
|
db_project = get_project(db, project_id)
|
||||||
if not db_project:
|
if not db_project:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
update_data = project.model_dump(exclude_unset=True)
|
update_data = project.model_dump(exclude_unset=True)
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(db_project, key, value)
|
setattr(db_project, key, value)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_project)
|
db.refresh(db_project)
|
||||||
return db_project
|
return db_project
|
||||||
|
|
||||||
|
|
||||||
def delete_project(db: Session, project_id: int) -> bool:
|
def delete_project(db: Session, project_id: int) -> bool:
|
||||||
db_project = get_project(db, project_id)
|
db_project = get_project(db, project_id)
|
||||||
if not db_project:
|
if not db_project:
|
||||||
return False
|
return False
|
||||||
db.delete(db_project)
|
db.delete(db_project)
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# 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
|
# Validate status against project's statuses
|
||||||
project = get_project(db, task.project_id)
|
project = get_project(db, task.project_id)
|
||||||
if project and task.status not in project.statuses:
|
if project and task.status not in project.statuses:
|
||||||
raise ValueError(f"Invalid status '{task.status}'. Must be one of: {', '.join(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(
|
||||||
models.Task.parent_task_id == task.parent_task_id
|
models.Task.parent_task_id == task.parent_task_id
|
||||||
).count()
|
).count()
|
||||||
else:
|
else:
|
||||||
max_order = db.query(models.Task).filter(
|
max_order = db.query(models.Task).filter(
|
||||||
models.Task.project_id == task.project_id,
|
models.Task.project_id == task.project_id,
|
||||||
models.Task.parent_task_id.is_(None)
|
models.Task.parent_task_id.is_(None)
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
task_data = task.model_dump()
|
task_data = task.model_dump()
|
||||||
if "sort_order" not in task_data or task_data["sort_order"] == 0:
|
if "sort_order" not in task_data or task_data["sort_order"] == 0:
|
||||||
task_data["sort_order"] = max_order
|
task_data["sort_order"] = max_order
|
||||||
|
|
||||||
db_task = models.Task(**task_data)
|
db_task = models.Task(**task_data)
|
||||||
db.add(db_task)
|
db.add(db_task)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_task)
|
db.refresh(db_task)
|
||||||
return db_task
|
return db_task
|
||||||
|
|
||||||
|
|
||||||
def get_task(db: Session, task_id: int) -> Optional[models.Task]:
|
def get_task(db: Session, task_id: int) -> Optional[models.Task]:
|
||||||
return db.query(models.Task).filter(models.Task.id == task_id).first()
|
return db.query(models.Task).filter(models.Task.id == task_id).first()
|
||||||
|
|
||||||
|
|
||||||
def get_tasks_by_project(db: Session, project_id: int) -> List[models.Task]:
|
def get_tasks_by_project(db: Session, project_id: int) -> List[models.Task]:
|
||||||
return db.query(models.Task).filter(models.Task.project_id == project_id).all()
|
return db.query(models.Task).filter(models.Task.project_id == project_id).all()
|
||||||
|
|
||||||
|
|
||||||
def get_root_tasks(db: Session, project_id: int) -> List[models.Task]:
|
def get_root_tasks(db: Session, project_id: int) -> List[models.Task]:
|
||||||
"""Get all root-level tasks (no parent) for a project"""
|
"""Get all root-level tasks (no parent) for a project"""
|
||||||
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.parent_task_id.is_(None)
|
models.Task.parent_task_id.is_(None)
|
||||||
).order_by(models.Task.sort_order).all()
|
).order_by(models.Task.sort_order).all()
|
||||||
|
|
||||||
|
|
||||||
def get_task_with_subtasks(db: Session, task_id: int) -> Optional[models.Task]:
|
def get_task_with_subtasks(db: Session, task_id: int) -> Optional[models.Task]:
|
||||||
"""Recursively load a task with all its subtasks"""
|
"""Recursively load a task with all its subtasks"""
|
||||||
return db.query(models.Task).options(
|
return db.query(models.Task).options(
|
||||||
joinedload(models.Task.subtasks)
|
joinedload(models.Task.subtasks)
|
||||||
).filter(models.Task.id == task_id).first()
|
).filter(models.Task.id == task_id).first()
|
||||||
|
|
||||||
|
|
||||||
def check_and_update_parent_status(db: Session, parent_id: int):
|
def check_and_update_parent_status(db: Session, parent_id: int):
|
||||||
"""Check if all children of a parent are done, and mark parent as done if so"""
|
"""Check if all children of a parent are done, and mark parent as done if so"""
|
||||||
# Get all children of this parent
|
# Get all children of this parent
|
||||||
children = db.query(models.Task).filter(
|
children = db.query(models.Task).filter(
|
||||||
models.Task.parent_task_id == parent_id
|
models.Task.parent_task_id == parent_id
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
# If no children, nothing to do
|
# If no children, nothing to do
|
||||||
if not children:
|
if not children:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if all children are done
|
# Check if all children are done
|
||||||
all_done = all(child.status == "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 != "done":
|
if parent and parent.status != "done":
|
||||||
parent.status = "done"
|
parent.status = "done"
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Recursively check grandparent
|
# Recursively check grandparent
|
||||||
if parent.parent_task_id:
|
if parent.parent_task_id:
|
||||||
check_and_update_parent_status(db, parent.parent_task_id)
|
check_and_update_parent_status(db, parent.parent_task_id)
|
||||||
|
|
||||||
|
|
||||||
def update_task(
|
def update_task(
|
||||||
db: Session, task_id: int, task: schemas.TaskUpdate
|
db: Session, task_id: int, task: schemas.TaskUpdate
|
||||||
) -> Optional[models.Task]:
|
) -> Optional[models.Task]:
|
||||||
db_task = get_task(db, task_id)
|
db_task = get_task(db, task_id)
|
||||||
if not db_task:
|
if not db_task:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
update_data = task.model_dump(exclude_unset=True)
|
update_data = task.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Validate status against project's statuses 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)
|
project = get_project(db, db_task.project_id)
|
||||||
if project and update_data["status"] not in project.statuses:
|
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)}")
|
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:
|
else:
|
||||||
status_changed = False
|
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)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
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 == "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
|
||||||
|
|
||||||
|
|
||||||
def delete_task(db: Session, task_id: int) -> bool:
|
def delete_task(db: Session, task_id: int) -> bool:
|
||||||
db_task = get_task(db, task_id)
|
db_task = get_task(db, task_id)
|
||||||
if not db_task:
|
if not db_task:
|
||||||
return False
|
return False
|
||||||
db.delete(db_task)
|
db.delete(db_task)
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def get_tasks_by_status(db: Session, project_id: int, status: str) -> 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
|
# Validate status against project's statuses
|
||||||
project = get_project(db, project_id)
|
project = get_project(db, project_id)
|
||||||
if project and status not in project.statuses:
|
if project and status not in project.statuses:
|
||||||
raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(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
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
# ========== BLOCKER CRUD ==========
|
||||||
|
|
||||||
|
def _has_cycle(db: Session, start_id: int, target_id: int) -> bool:
|
||||||
|
"""BFS from start_id following its blockers. Returns True if target_id is reachable,
|
||||||
|
which would mean adding target_id as a blocker of start_id creates a cycle."""
|
||||||
|
visited = set()
|
||||||
|
queue = [start_id]
|
||||||
|
while queue:
|
||||||
|
current = queue.pop(0)
|
||||||
|
if current == target_id:
|
||||||
|
return True
|
||||||
|
if current in visited:
|
||||||
|
continue
|
||||||
|
visited.add(current)
|
||||||
|
task = db.query(models.Task).filter(models.Task.id == current).first()
|
||||||
|
if task:
|
||||||
|
for b in task.blockers:
|
||||||
|
if b.id not in visited:
|
||||||
|
queue.append(b.id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def add_blocker(db: Session, task_id: int, blocker_id: int) -> models.Task:
|
||||||
|
"""Add blocker_id as a prerequisite of task_id.
|
||||||
|
Raises ValueError on self-reference or cycle."""
|
||||||
|
if task_id == blocker_id:
|
||||||
|
raise ValueError("A task cannot block itself")
|
||||||
|
|
||||||
|
task = get_task(db, task_id)
|
||||||
|
blocker = get_task(db, blocker_id)
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise ValueError("Task not found")
|
||||||
|
if not blocker:
|
||||||
|
raise ValueError("Blocker task not found")
|
||||||
|
|
||||||
|
# Already linked — idempotent
|
||||||
|
if any(b.id == blocker_id for b in task.blockers):
|
||||||
|
return task
|
||||||
|
|
||||||
|
# Cycle detection: would blocker_id eventually depend on task_id?
|
||||||
|
if _has_cycle(db, blocker_id, task_id):
|
||||||
|
raise ValueError("Adding this blocker would create a circular dependency")
|
||||||
|
|
||||||
|
task.blockers.append(blocker)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def remove_blocker(db: Session, task_id: int, blocker_id: int) -> bool:
|
||||||
|
"""Remove blocker_id as a prerequisite of task_id."""
|
||||||
|
task = get_task(db, task_id)
|
||||||
|
blocker = get_task(db, blocker_id)
|
||||||
|
|
||||||
|
if not task or not blocker:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not any(b.id == blocker_id for b in task.blockers):
|
||||||
|
return False
|
||||||
|
|
||||||
|
task.blockers.remove(blocker)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_with_blockers(db: Session, task_id: int) -> Optional[models.Task]:
|
||||||
|
"""Get a task including its blockers and blocking lists."""
|
||||||
|
return db.query(models.Task).filter(models.Task.id == task_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_actionable_tasks(db: Session) -> List[dict]:
|
||||||
|
"""Return all non-done tasks that have no incomplete blockers, with project name."""
|
||||||
|
tasks = db.query(models.Task).filter(
|
||||||
|
models.Task.status != "done"
|
||||||
|
).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for task in tasks:
|
||||||
|
incomplete_blockers = [b for b in task.blockers if b.status != "done"]
|
||||||
|
if not incomplete_blockers:
|
||||||
|
result.append({
|
||||||
|
"id": task.id,
|
||||||
|
"title": task.title,
|
||||||
|
"project_id": task.project_id,
|
||||||
|
"project_name": task.project.name,
|
||||||
|
"status": task.status,
|
||||||
|
"estimated_minutes": task.estimated_minutes,
|
||||||
|
"tags": task.tags,
|
||||||
|
"flag_color": task.flag_color,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|||||||
@@ -1,324 +1,368 @@
|
|||||||
from fastapi import FastAPI, Depends, HTTPException, UploadFile, File
|
from fastapi import FastAPI, Depends, HTTPException, UploadFile, File
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from . import models, schemas, crud
|
from . import models, schemas, crud
|
||||||
from .database import engine, get_db
|
from .database import engine, get_db
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
|
|
||||||
# Create database tables
|
# Create database tables
|
||||||
models.Base.metadata.create_all(bind=engine)
|
models.Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.api_title,
|
title=settings.api_title,
|
||||||
description=settings.api_description,
|
description=settings.api_description,
|
||||||
version=settings.api_version
|
version=settings.api_version
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS middleware for frontend
|
# CORS middleware for frontend
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins_list,
|
allow_origins=settings.cors_origins_list,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ========== PROJECT ENDPOINTS ==========
|
# ========== PROJECT ENDPOINTS ==========
|
||||||
|
|
||||||
@app.get("/api/projects", response_model=List[schemas.Project])
|
@app.get("/api/projects", response_model=List[schemas.Project])
|
||||||
def list_projects(
|
def list_projects(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
archived: Optional[bool] = None,
|
archived: Optional[bool] = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""List all projects with optional archive filter"""
|
"""List all projects with optional archive filter"""
|
||||||
return crud.get_projects(db, skip=skip, limit=limit, archived=archived)
|
return crud.get_projects(db, skip=skip, limit=limit, archived=archived)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/projects", response_model=schemas.Project, status_code=201)
|
@app.post("/api/projects", response_model=schemas.Project, status_code=201)
|
||||||
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)):
|
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)):
|
||||||
"""Create a new project"""
|
"""Create a new project"""
|
||||||
return crud.create_project(db, project)
|
return crud.create_project(db, project)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/projects/{project_id}", response_model=schemas.Project)
|
@app.get("/api/projects/{project_id}", response_model=schemas.Project)
|
||||||
def get_project(project_id: int, db: Session = Depends(get_db)):
|
def get_project(project_id: int, db: Session = Depends(get_db)):
|
||||||
"""Get a specific project"""
|
"""Get a specific project"""
|
||||||
db_project = crud.get_project(db, project_id)
|
db_project = crud.get_project(db, project_id)
|
||||||
if not db_project:
|
if not db_project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
return db_project
|
return db_project
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/projects/{project_id}", response_model=schemas.Project)
|
@app.put("/api/projects/{project_id}", response_model=schemas.Project)
|
||||||
def update_project(
|
def update_project(
|
||||||
project_id: int, project: schemas.ProjectUpdate, db: Session = Depends(get_db)
|
project_id: int, project: schemas.ProjectUpdate, db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Update a project"""
|
"""Update a project"""
|
||||||
db_project = crud.update_project(db, project_id, project)
|
db_project = crud.update_project(db, project_id, project)
|
||||||
if not db_project:
|
if not db_project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
return db_project
|
return db_project
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/projects/{project_id}", status_code=204)
|
@app.delete("/api/projects/{project_id}", status_code=204)
|
||||||
def delete_project(project_id: int, db: Session = Depends(get_db)):
|
def delete_project(project_id: int, db: Session = Depends(get_db)):
|
||||||
"""Delete a project and all its tasks"""
|
"""Delete a project and all its tasks"""
|
||||||
if not crud.delete_project(db, project_id):
|
if not crud.delete_project(db, project_id):
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ========== TASK ENDPOINTS ==========
|
# ========== TASK ENDPOINTS ==========
|
||||||
|
|
||||||
@app.get("/api/projects/{project_id}/tasks", response_model=List[schemas.Task])
|
@app.get("/api/projects/{project_id}/tasks", response_model=List[schemas.Task])
|
||||||
def list_project_tasks(project_id: int, db: Session = Depends(get_db)):
|
def list_project_tasks(project_id: int, db: Session = Depends(get_db)):
|
||||||
"""List all tasks for a project"""
|
"""List all tasks for a project"""
|
||||||
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")
|
||||||
return crud.get_tasks_by_project(db, project_id)
|
return crud.get_tasks_by_project(db, project_id)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/projects/{project_id}/tasks/tree", response_model=List[schemas.TaskWithSubtasks])
|
@app.get("/api/projects/{project_id}/tasks/tree", response_model=List[schemas.TaskWithSubtasks])
|
||||||
def get_project_task_tree(project_id: int, db: Session = Depends(get_db)):
|
def get_project_task_tree(project_id: int, db: Session = Depends(get_db)):
|
||||||
"""Get the task tree (root tasks with nested subtasks) for a project"""
|
"""Get the task tree (root tasks with nested subtasks) for a project"""
|
||||||
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")
|
||||||
|
|
||||||
root_tasks = crud.get_root_tasks(db, project_id)
|
root_tasks = crud.get_root_tasks(db, project_id)
|
||||||
|
|
||||||
def build_tree(task):
|
def build_tree(task):
|
||||||
task_dict = schemas.TaskWithSubtasks.model_validate(task)
|
task_dict = schemas.TaskWithSubtasks.model_validate(task)
|
||||||
task_dict.subtasks = [build_tree(subtask) for subtask in task.subtasks]
|
task_dict.subtasks = [build_tree(subtask) for subtask in task.subtasks]
|
||||||
return task_dict
|
return task_dict
|
||||||
|
|
||||||
return [build_tree(task) for task in root_tasks]
|
return [build_tree(task) for task in root_tasks]
|
||||||
|
|
||||||
|
|
||||||
@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: str,
|
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:
|
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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(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)
|
||||||
def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
|
def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
|
||||||
"""Create a new task"""
|
"""Create a new task"""
|
||||||
if not crud.get_project(db, task.project_id):
|
if not crud.get_project(db, task.project_id):
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
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:
|
try:
|
||||||
return crud.create_task(db, task)
|
return crud.create_task(db, task)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(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)
|
||||||
def get_task(task_id: int, db: Session = Depends(get_db)):
|
def get_task(task_id: int, db: Session = Depends(get_db)):
|
||||||
"""Get a specific task"""
|
"""Get a specific task"""
|
||||||
db_task = crud.get_task(db, task_id)
|
db_task = crud.get_task(db, task_id)
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@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:
|
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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(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)
|
||||||
def delete_task(task_id: int, db: Session = Depends(get_db)):
|
def delete_task(task_id: int, db: Session = Depends(get_db)):
|
||||||
"""Delete a task and all its subtasks"""
|
"""Delete a task and all its subtasks"""
|
||||||
if not crud.delete_task(db, task_id):
|
if not crud.delete_task(db, task_id):
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ========== SEARCH ENDPOINT ==========
|
# ========== BLOCKER ENDPOINTS ==========
|
||||||
|
|
||||||
@app.get("/api/search", response_model=List[schemas.Task])
|
@app.get("/api/tasks/{task_id}/blockers", response_model=List[schemas.BlockerInfo])
|
||||||
def search_tasks(
|
def get_task_blockers(task_id: int, db: Session = Depends(get_db)):
|
||||||
query: str,
|
"""Get all tasks that are blocking a given task."""
|
||||||
project_ids: Optional[str] = None,
|
task = crud.get_task_with_blockers(db, task_id)
|
||||||
db: Session = Depends(get_db)
|
if not task:
|
||||||
):
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
"""
|
return task.blockers
|
||||||
Search tasks across projects by title, description, and tags.
|
|
||||||
|
|
||||||
Args:
|
@app.get("/api/tasks/{task_id}/blocking", response_model=List[schemas.BlockerInfo])
|
||||||
query: Search term to match against title, description, and tags
|
def get_tasks_this_blocks(task_id: int, db: Session = Depends(get_db)):
|
||||||
project_ids: Comma-separated list of project IDs to search in (optional, searches all if not provided)
|
"""Get all tasks that this task is currently blocking."""
|
||||||
"""
|
task = crud.get_task_with_blockers(db, task_id)
|
||||||
# Parse project IDs if provided
|
if not task:
|
||||||
project_id_list = None
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
if project_ids:
|
return task.blocking
|
||||||
try:
|
|
||||||
project_id_list = [int(pid.strip()) for pid in project_ids.split(',') if pid.strip()]
|
|
||||||
except ValueError:
|
@app.post("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=201)
|
||||||
raise HTTPException(status_code=400, detail="Invalid project_ids format")
|
def add_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Add blocker_id as a prerequisite of task_id."""
|
||||||
# Build query
|
try:
|
||||||
tasks_query = db.query(models.Task)
|
crud.add_blocker(db, task_id, blocker_id)
|
||||||
|
return {"status": "ok"}
|
||||||
# Filter by project IDs if specified
|
except ValueError as e:
|
||||||
if project_id_list:
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
tasks_query = tasks_query.filter(models.Task.project_id.in_(project_id_list))
|
|
||||||
|
|
||||||
# Search in title, description, and tags
|
@app.delete("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=204)
|
||||||
search_term = f"%{query}%"
|
def remove_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)):
|
||||||
tasks = tasks_query.filter(
|
"""Remove blocker_id as a prerequisite of task_id."""
|
||||||
(models.Task.title.ilike(search_term)) |
|
if not crud.remove_blocker(db, task_id, blocker_id):
|
||||||
(models.Task.description.ilike(search_term)) |
|
raise HTTPException(status_code=404, detail="Blocker relationship not found")
|
||||||
(models.Task.tags.contains([query])) # Exact tag match
|
return None
|
||||||
).all()
|
|
||||||
|
|
||||||
return tasks
|
@app.get("/api/actionable", response_model=List[schemas.ActionableTask])
|
||||||
|
def get_actionable_tasks(db: Session = Depends(get_db)):
|
||||||
|
"""Get all non-done tasks with no incomplete blockers, across all projects."""
|
||||||
# ========== JSON IMPORT ENDPOINT ==========
|
return crud.get_actionable_tasks(db)
|
||||||
|
|
||||||
def _validate_task_statuses_recursive(
|
|
||||||
tasks: List[schemas.ImportSubtask],
|
# ========== SEARCH ENDPOINT ==========
|
||||||
valid_statuses: List[str],
|
|
||||||
path: str = ""
|
@app.get("/api/search", response_model=List[schemas.Task])
|
||||||
) -> None:
|
def search_tasks(
|
||||||
"""Recursively validate all task statuses against the project's valid statuses"""
|
query: str,
|
||||||
for idx, task_data in enumerate(tasks):
|
project_ids: Optional[str] = None,
|
||||||
task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]"
|
db: Session = Depends(get_db)
|
||||||
if task_data.status not in valid_statuses:
|
):
|
||||||
raise ValueError(
|
"""
|
||||||
f"Invalid status '{task_data.status}' at {task_path}. "
|
Search tasks across projects by title, description, and tags.
|
||||||
f"Must be one of: {', '.join(valid_statuses)}"
|
|
||||||
)
|
Args:
|
||||||
if task_data.subtasks:
|
query: Search term to match against title, description, and tags
|
||||||
_validate_task_statuses_recursive(
|
project_ids: Comma-separated list of project IDs to search in (optional, searches all if not provided)
|
||||||
task_data.subtasks,
|
"""
|
||||||
valid_statuses,
|
# Parse project IDs if provided
|
||||||
f"{task_path}.subtasks"
|
project_id_list = None
|
||||||
)
|
if project_ids:
|
||||||
|
try:
|
||||||
|
project_id_list = [int(pid.strip()) for pid in project_ids.split(',') if pid.strip()]
|
||||||
def _import_tasks_recursive(
|
except ValueError:
|
||||||
db: Session,
|
raise HTTPException(status_code=400, detail="Invalid project_ids format")
|
||||||
project_id: int,
|
|
||||||
tasks: List[schemas.ImportSubtask],
|
# Build query
|
||||||
parent_id: Optional[int] = None,
|
tasks_query = db.query(models.Task)
|
||||||
count: int = 0
|
|
||||||
) -> int:
|
# Filter by project IDs if specified
|
||||||
"""Recursively import tasks and their subtasks"""
|
if project_id_list:
|
||||||
for idx, task_data in enumerate(tasks):
|
tasks_query = tasks_query.filter(models.Task.project_id.in_(project_id_list))
|
||||||
task = schemas.TaskCreate(
|
|
||||||
project_id=project_id,
|
# Search in title, description, and tags
|
||||||
parent_task_id=parent_id,
|
search_term = f"%{query}%"
|
||||||
title=task_data.title,
|
tasks = tasks_query.filter(
|
||||||
description=task_data.description,
|
(models.Task.title.ilike(search_term)) |
|
||||||
status=task_data.status,
|
(models.Task.description.ilike(search_term)) |
|
||||||
estimated_minutes=task_data.estimated_minutes,
|
(models.Task.tags.contains([query])) # Exact tag match
|
||||||
tags=task_data.tags,
|
).all()
|
||||||
flag_color=task_data.flag_color,
|
|
||||||
sort_order=idx
|
return tasks
|
||||||
)
|
|
||||||
db_task = crud.create_task(db, task)
|
|
||||||
count += 1
|
# ========== JSON IMPORT ENDPOINT ==========
|
||||||
|
|
||||||
if task_data.subtasks:
|
def _validate_task_statuses_recursive(
|
||||||
count = _import_tasks_recursive(
|
tasks: List[schemas.ImportSubtask],
|
||||||
db, project_id, task_data.subtasks, db_task.id, count
|
valid_statuses: List[str],
|
||||||
)
|
path: str = ""
|
||||||
|
) -> None:
|
||||||
return count
|
"""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}]"
|
||||||
@app.post("/api/import-json", response_model=schemas.ImportResult)
|
if task_data.status not in valid_statuses:
|
||||||
def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_db)):
|
raise ValueError(
|
||||||
"""
|
f"Invalid status '{task_data.status}' at {task_path}. "
|
||||||
Import a project with nested tasks from JSON.
|
f"Must be one of: {', '.join(valid_statuses)}"
|
||||||
|
)
|
||||||
Expected format:
|
if task_data.subtasks:
|
||||||
{
|
_validate_task_statuses_recursive(
|
||||||
"project": {
|
task_data.subtasks,
|
||||||
"name": "Project Name",
|
valid_statuses,
|
||||||
"description": "Optional description",
|
f"{task_path}.subtasks"
|
||||||
"statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional
|
)
|
||||||
},
|
|
||||||
"tasks": [
|
|
||||||
{
|
def _import_tasks_recursive(
|
||||||
"title": "Task 1",
|
db: Session,
|
||||||
"description": "Optional",
|
project_id: int,
|
||||||
"status": "backlog",
|
tasks: List[schemas.ImportSubtask],
|
||||||
"subtasks": [
|
parent_id: Optional[int] = None,
|
||||||
{
|
count: int = 0
|
||||||
"title": "Subtask 1.1",
|
) -> int:
|
||||||
"status": "backlog",
|
"""Recursively import tasks and their subtasks"""
|
||||||
"subtasks": []
|
for idx, task_data in enumerate(tasks):
|
||||||
}
|
task = schemas.TaskCreate(
|
||||||
]
|
project_id=project_id,
|
||||||
}
|
parent_task_id=parent_id,
|
||||||
]
|
title=task_data.title,
|
||||||
}
|
description=task_data.description,
|
||||||
"""
|
status=task_data.status,
|
||||||
# Create the project with optional statuses
|
estimated_minutes=task_data.estimated_minutes,
|
||||||
project = crud.create_project(
|
tags=task_data.tags,
|
||||||
db,
|
flag_color=task_data.flag_color,
|
||||||
schemas.ProjectCreate(
|
sort_order=idx
|
||||||
name=import_data.project.name,
|
)
|
||||||
description=import_data.project.description,
|
db_task = crud.create_task(db, task)
|
||||||
statuses=import_data.project.statuses
|
count += 1
|
||||||
)
|
|
||||||
)
|
if task_data.subtasks:
|
||||||
|
count = _import_tasks_recursive(
|
||||||
# Validate all task statuses before importing
|
db, project_id, task_data.subtasks, db_task.id, count
|
||||||
if import_data.tasks:
|
)
|
||||||
try:
|
|
||||||
_validate_task_statuses_recursive(import_data.tasks, project.statuses)
|
return count
|
||||||
except ValueError as e:
|
|
||||||
# Rollback the project creation if validation fails
|
|
||||||
db.delete(project)
|
@app.post("/api/import-json", response_model=schemas.ImportResult)
|
||||||
db.commit()
|
def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_db)):
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
"""
|
||||||
|
Import a project with nested tasks from JSON.
|
||||||
# Recursively import tasks
|
|
||||||
tasks_created = _import_tasks_recursive(
|
Expected format:
|
||||||
db, project.id, import_data.tasks
|
{
|
||||||
)
|
"project": {
|
||||||
|
"name": "Project Name",
|
||||||
return schemas.ImportResult(
|
"description": "Optional description",
|
||||||
project_id=project.id,
|
"statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional
|
||||||
project_name=project.name,
|
},
|
||||||
tasks_created=tasks_created
|
"tasks": [
|
||||||
)
|
{
|
||||||
|
"title": "Task 1",
|
||||||
|
"description": "Optional",
|
||||||
@app.get("/")
|
"status": "backlog",
|
||||||
def root():
|
"subtasks": [
|
||||||
"""API health check"""
|
{
|
||||||
return {
|
"title": "Subtask 1.1",
|
||||||
"status": "online",
|
"status": "backlog",
|
||||||
"message": "Break It Down (BIT) API",
|
"subtasks": []
|
||||||
"docs": "/docs"
|
}
|
||||||
}
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# Create the project with optional statuses
|
||||||
|
project = crud.create_project(
|
||||||
|
db,
|
||||||
|
schemas.ProjectCreate(
|
||||||
|
name=import_data.project.name,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
return schemas.ImportResult(
|
||||||
|
project_id=project.id,
|
||||||
|
project_name=project.name,
|
||||||
|
tasks_created=tasks_created
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def root():
|
||||||
|
"""API health check"""
|
||||||
|
return {
|
||||||
|
"status": "online",
|
||||||
|
"message": "Break It Down (BIT) API",
|
||||||
|
"docs": "/docs"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,42 +1,61 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean
|
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean, Table
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from .database import Base
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
# Default statuses for new projects
|
# Default statuses for new projects
|
||||||
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
|
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
|
||||||
|
|
||||||
|
|
||||||
class Project(Base):
|
# Association table for task blocker relationships (many-to-many)
|
||||||
__tablename__ = "projects"
|
task_blockers = Table(
|
||||||
|
"task_blockers",
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
Base.metadata,
|
||||||
name = Column(String(255), nullable=False)
|
Column("task_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
|
||||||
description = Column(Text, nullable=True)
|
Column("blocked_by_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
|
||||||
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
|
)
|
||||||
is_archived = Column(Boolean, default=False, nullable=False)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
class Project(Base):
|
||||||
|
__tablename__ = "projects"
|
||||||
tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
class Task(Base):
|
description = Column(Text, nullable=True)
|
||||||
__tablename__ = "tasks"
|
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
|
||||||
|
is_archived = Column(Boolean, default=False, nullable=False)
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
|
|
||||||
title = Column(String(500), nullable=False)
|
tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")
|
||||||
description = Column(Text, nullable=True)
|
|
||||||
status = Column(String(50), default="backlog", nullable=False)
|
|
||||||
sort_order = Column(Integer, default=0)
|
class Task(Base):
|
||||||
estimated_minutes = Column(Integer, nullable=True)
|
__tablename__ = "tasks"
|
||||||
tags = Column(JSON, nullable=True)
|
|
||||||
flag_color = Column(String(50), nullable=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
|
||||||
|
title = Column(String(500), nullable=False)
|
||||||
project = relationship("Project", back_populates="tasks")
|
description = Column(Text, nullable=True)
|
||||||
parent = relationship("Task", remote_side=[id], backref="subtasks")
|
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)
|
||||||
|
flag_color = Column(String(50), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
project = relationship("Project", back_populates="tasks")
|
||||||
|
parent = relationship("Task", remote_side=[id], backref="subtasks")
|
||||||
|
|
||||||
|
# blockers: tasks that must be done before this task can start
|
||||||
|
# blocking: tasks that this task is holding up
|
||||||
|
blockers = relationship(
|
||||||
|
"Task",
|
||||||
|
secondary=task_blockers,
|
||||||
|
primaryjoin=lambda: Task.id == task_blockers.c.task_id,
|
||||||
|
secondaryjoin=lambda: Task.id == task_blockers.c.blocked_by_id,
|
||||||
|
backref="blocking",
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,107 +1,138 @@
|
|||||||
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 DEFAULT_STATUSES
|
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: str = "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
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
flag_color: Optional[str] = None
|
flag_color: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(TaskBase):
|
class TaskCreate(TaskBase):
|
||||||
project_id: int
|
project_id: int
|
||||||
|
|
||||||
|
|
||||||
class TaskUpdate(BaseModel):
|
class TaskUpdate(BaseModel):
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
status: Optional[str] = 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
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
flag_color: Optional[str] = None
|
flag_color: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class Task(TaskBase):
|
class Task(TaskBase):
|
||||||
id: int
|
id: int
|
||||||
project_id: int
|
project_id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class TaskWithSubtasks(Task):
|
class TaskWithSubtasks(Task):
|
||||||
subtasks: List['TaskWithSubtasks'] = []
|
subtasks: List['TaskWithSubtasks'] = []
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
# Project Schemas
|
class BlockerInfo(BaseModel):
|
||||||
class ProjectBase(BaseModel):
|
"""Lightweight task info used when listing blockers/blocking relationships."""
|
||||||
name: str
|
id: int
|
||||||
description: Optional[str] = None
|
title: str
|
||||||
|
project_id: int
|
||||||
|
status: str
|
||||||
class ProjectCreate(ProjectBase):
|
|
||||||
statuses: Optional[List[str]] = None
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(BaseModel):
|
class TaskWithBlockers(Task):
|
||||||
name: Optional[str] = None
|
blockers: List[BlockerInfo] = []
|
||||||
description: Optional[str] = None
|
blocking: List[BlockerInfo] = []
|
||||||
statuses: Optional[List[str]] = None
|
|
||||||
is_archived: Optional[bool] = None
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class Project(ProjectBase):
|
class ActionableTask(BaseModel):
|
||||||
id: int
|
"""A task that is ready to work on — not done, and all blockers are resolved."""
|
||||||
statuses: List[str]
|
id: int
|
||||||
is_archived: bool
|
title: str
|
||||||
created_at: datetime
|
project_id: int
|
||||||
updated_at: datetime
|
project_name: str
|
||||||
|
status: str
|
||||||
model_config = ConfigDict(from_attributes=True)
|
estimated_minutes: Optional[int] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
flag_color: Optional[str] = None
|
||||||
class ProjectWithTasks(Project):
|
|
||||||
tasks: List[Task] = []
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
# Project Schemas
|
||||||
|
class ProjectBase(BaseModel):
|
||||||
# JSON Import Schemas
|
name: str
|
||||||
class ImportSubtask(BaseModel):
|
description: Optional[str] = None
|
||||||
title: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
status: str = "backlog"
|
class ProjectCreate(ProjectBase):
|
||||||
estimated_minutes: Optional[int] = None
|
statuses: Optional[List[str]] = None
|
||||||
tags: Optional[List[str]] = None
|
|
||||||
flag_color: Optional[str] = None
|
|
||||||
subtasks: List['ImportSubtask'] = []
|
class ProjectUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
class ImportProject(BaseModel):
|
statuses: Optional[List[str]] = None
|
||||||
name: str
|
is_archived: Optional[bool] = None
|
||||||
description: Optional[str] = None
|
|
||||||
statuses: Optional[List[str]] = None
|
|
||||||
|
class Project(ProjectBase):
|
||||||
|
id: int
|
||||||
class ImportData(BaseModel):
|
statuses: List[str]
|
||||||
project: ImportProject
|
is_archived: bool
|
||||||
tasks: List[ImportSubtask] = []
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
class ImportResult(BaseModel):
|
model_config = ConfigDict(from_attributes=True)
|
||||||
project_id: int
|
|
||||||
project_name: str
|
|
||||||
tasks_created: int
|
class ProjectWithTasks(Project):
|
||||||
|
tasks: List[Task] = []
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# JSON Import Schemas
|
||||||
|
class ImportSubtask(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: str = "backlog"
|
||||||
|
estimated_minutes: Optional[int] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
flag_color: Optional[str] = None
|
||||||
|
subtasks: List['ImportSubtask'] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ImportProject(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
statuses: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ImportData(BaseModel):
|
||||||
|
project: ImportProject
|
||||||
|
tasks: List[ImportSubtask] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ImportResult(BaseModel):
|
||||||
|
project_id: int
|
||||||
|
project_name: str
|
||||||
|
tasks_created: int
|
||||||
|
|||||||
40
backend/migrate_add_blockers.py
Normal file
40
backend/migrate_add_blockers.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
Migration script to add the task_blockers association table.
|
||||||
|
Run this once if you have an existing database.
|
||||||
|
|
||||||
|
Usage (from inside the backend container or with the venv active):
|
||||||
|
python migrate_add_blockers.py
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
db_path = os.path.join(os.path.dirname(__file__), 'bit.db')
|
||||||
|
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
print(f"Database not found at {db_path}")
|
||||||
|
print("No migration needed — new database will be created with the correct schema.")
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if the table already exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='task_blockers'")
|
||||||
|
if cursor.fetchone():
|
||||||
|
print("Table 'task_blockers' already exists. Migration not needed.")
|
||||||
|
else:
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE task_blockers (
|
||||||
|
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
blocked_by_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (task_id, blocked_by_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
print("Successfully created 'task_blockers' table.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during migration: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
@@ -1,34 +1,56 @@
|
|||||||
import { Routes, Route } from 'react-router-dom'
|
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import ProjectList from './pages/ProjectList'
|
import { Zap } from 'lucide-react'
|
||||||
import ProjectView from './pages/ProjectView'
|
import ProjectList from './pages/ProjectList'
|
||||||
import SearchBar from './components/SearchBar'
|
import ProjectView from './pages/ProjectView'
|
||||||
|
import ActionableView from './pages/ActionableView'
|
||||||
function App() {
|
import SearchBar from './components/SearchBar'
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-cyber-dark">
|
function App() {
|
||||||
<header className="border-b border-cyber-orange/30 bg-cyber-darkest">
|
const navigate = useNavigate()
|
||||||
<div className="container mx-auto px-4 py-4">
|
const location = useLocation()
|
||||||
<div className="flex justify-between items-center">
|
const isActionable = location.pathname === '/actionable'
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-cyber-orange">
|
return (
|
||||||
BIT
|
<div className="min-h-screen bg-cyber-dark">
|
||||||
<span className="ml-3 text-sm text-gray-500">Break It Down</span>
|
<header className="border-b border-cyber-orange/30 bg-cyber-darkest">
|
||||||
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
|
<div className="container mx-auto px-4 py-4">
|
||||||
</h1>
|
<div className="flex justify-between items-center">
|
||||||
</div>
|
<div className="flex items-center gap-4">
|
||||||
<SearchBar />
|
<h1
|
||||||
</div>
|
className="text-2xl font-bold text-cyber-orange cursor-pointer"
|
||||||
</div>
|
onClick={() => navigate('/')}
|
||||||
</header>
|
>
|
||||||
|
BIT
|
||||||
<main className="container mx-auto px-4 py-8">
|
<span className="ml-3 text-sm text-gray-500">Break It Down</span>
|
||||||
<Routes>
|
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
|
||||||
<Route path="/" element={<ProjectList />} />
|
</h1>
|
||||||
<Route path="/project/:projectId" element={<ProjectView />} />
|
<button
|
||||||
</Routes>
|
onClick={() => navigate(isActionable ? '/' : '/actionable')}
|
||||||
</main>
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-all ${
|
||||||
</div>
|
isActionable
|
||||||
)
|
? 'bg-cyber-orange text-cyber-darkest'
|
||||||
}
|
: 'border border-cyber-orange/40 text-cyber-orange hover:bg-cyber-orange/10'
|
||||||
|
}`}
|
||||||
export default App
|
title="What can I do right now?"
|
||||||
|
>
|
||||||
|
<Zap size={14} />
|
||||||
|
Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SearchBar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<ProjectList />} />
|
||||||
|
<Route path="/project/:projectId" element={<ProjectView />} />
|
||||||
|
<Route path="/actionable" element={<ActionableView />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
|||||||
201
frontend/src/components/BlockerPanel.jsx
Normal file
201
frontend/src/components/BlockerPanel.jsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { X, Search, Lock, Unlock, AlertTriangle } from 'lucide-react'
|
||||||
|
import { getTaskBlockers, addBlocker, removeBlocker, searchTasks } from '../utils/api'
|
||||||
|
|
||||||
|
function BlockerPanel({ task, onClose, onUpdate }) {
|
||||||
|
const [blockers, setBlockers] = useState([])
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState([])
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const searchTimeout = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBlockers()
|
||||||
|
}, [task.id])
|
||||||
|
|
||||||
|
const loadBlockers = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await getTaskBlockers(task.id)
|
||||||
|
setBlockers(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = (query) => {
|
||||||
|
setSearchQuery(query)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (searchTimeout.current) clearTimeout(searchTimeout.current)
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
setSearchResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeout.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
setSearching(true)
|
||||||
|
const results = await searchTasks(query)
|
||||||
|
// Filter out the current task and tasks already blocking this one
|
||||||
|
const blockerIds = new Set(blockers.map(b => b.id))
|
||||||
|
const filtered = results.filter(t => t.id !== task.id && !blockerIds.has(t.id))
|
||||||
|
setSearchResults(filtered.slice(0, 8))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setSearching(false)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddBlocker = async (blocker) => {
|
||||||
|
try {
|
||||||
|
setError('')
|
||||||
|
await addBlocker(task.id, blocker.id)
|
||||||
|
setBlockers(prev => [...prev, blocker])
|
||||||
|
setSearchResults(prev => prev.filter(t => t.id !== blocker.id))
|
||||||
|
setSearchQuery('')
|
||||||
|
setSearchResults([])
|
||||||
|
onUpdate()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveBlocker = async (blockerId) => {
|
||||||
|
try {
|
||||||
|
setError('')
|
||||||
|
await removeBlocker(task.id, blockerId)
|
||||||
|
setBlockers(prev => prev.filter(b => b.id !== blockerId))
|
||||||
|
onUpdate()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const incompleteBlockers = blockers.filter(b => b.status !== 'done')
|
||||||
|
const isBlocked = incompleteBlockers.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-cyber-darkest border border-cyber-orange/50 rounded-lg w-full max-w-lg shadow-xl"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-cyber-orange/20">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isBlocked
|
||||||
|
? <Lock size={16} className="text-red-400" />
|
||||||
|
: <Unlock size={16} className="text-green-400" />
|
||||||
|
}
|
||||||
|
<span className="font-semibold text-gray-100 text-sm">Blockers</span>
|
||||||
|
<span className="text-xs text-gray-500 ml-1 truncate max-w-[200px]" title={task.title}>
|
||||||
|
— {task.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-200 transition-colors">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
{/* Status banner */}
|
||||||
|
{isBlocked ? (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-red-900/20 border border-red-500/30 rounded text-red-300 text-sm">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
<span>{incompleteBlockers.length} incomplete blocker{incompleteBlockers.length !== 1 ? 's' : ''} — this task is locked</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-green-900/20 border border-green-500/30 rounded text-green-300 text-sm">
|
||||||
|
<Unlock size={14} />
|
||||||
|
<span>No active blockers — this task is ready to work on</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current blockers list */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-gray-500 text-sm text-center py-2">Loading...</div>
|
||||||
|
) : blockers.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Blocked by</p>
|
||||||
|
{blockers.map(b => (
|
||||||
|
<div
|
||||||
|
key={b.id}
|
||||||
|
className="flex items-center justify-between px-3 py-2 bg-cyber-darker rounded border border-cyber-orange/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${b.status === 'done' ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||||
|
<span className="text-sm text-gray-200 truncate">{b.title}</span>
|
||||||
|
<span className="text-xs text-gray-500 flex-shrink-0">#{b.id}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveBlocker(b.id)}
|
||||||
|
className="text-gray-600 hover:text-red-400 transition-colors flex-shrink-0 ml-2"
|
||||||
|
title="Remove blocker"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-600 text-center py-1">No blockers added yet</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search to add blocker */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Add a blocker</p>
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => handleSearch(e.target.value)}
|
||||||
|
placeholder="Search tasks across all projects..."
|
||||||
|
className="w-full pl-9 pr-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search results */}
|
||||||
|
{(searchResults.length > 0 || searching) && (
|
||||||
|
<div className="mt-1 border border-cyber-orange/20 rounded overflow-hidden">
|
||||||
|
{searching && (
|
||||||
|
<div className="px-3 py-2 text-xs text-gray-500">Searching...</div>
|
||||||
|
)}
|
||||||
|
{searchResults.map(result => (
|
||||||
|
<button
|
||||||
|
key={result.id}
|
||||||
|
onClick={() => handleAddBlocker(result)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-left transition-colors border-b border-cyber-orange/10 last:border-0"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-200 truncate flex-1">{result.title}</span>
|
||||||
|
<span className="text-xs text-gray-600 flex-shrink-0">project #{result.project_id}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!searching && searchResults.length === 0 && searchQuery && (
|
||||||
|
<div className="px-3 py-2 text-xs text-gray-500">No matching tasks found</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="px-3 py-2 bg-red-900/30 border border-red-500/40 rounded text-red-300 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlockerPanel
|
||||||
@@ -1,405 +1,429 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText } from 'lucide-react'
|
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText, Lock } from 'lucide-react'
|
||||||
import { updateTask } from '../utils/api'
|
import { updateTask } from '../utils/api'
|
||||||
|
import BlockerPanel from './BlockerPanel'
|
||||||
const FLAG_COLORS = [
|
|
||||||
{ name: 'red', color: 'bg-red-500' },
|
const FLAG_COLORS = [
|
||||||
{ name: 'orange', color: 'bg-orange-500' },
|
{ name: 'red', color: 'bg-red-500' },
|
||||||
{ name: 'yellow', color: 'bg-yellow-500' },
|
{ name: 'orange', color: 'bg-orange-500' },
|
||||||
{ name: 'green', color: 'bg-green-500' },
|
{ name: 'yellow', color: 'bg-yellow-500' },
|
||||||
{ name: 'blue', color: 'bg-blue-500' },
|
{ name: 'green', color: 'bg-green-500' },
|
||||||
{ name: 'purple', color: 'bg-purple-500' },
|
{ name: 'blue', color: 'bg-blue-500' },
|
||||||
{ name: 'pink', color: 'bg-pink-500' }
|
{ name: 'purple', color: 'bg-purple-500' },
|
||||||
]
|
{ name: 'pink', color: 'bg-pink-500' }
|
||||||
|
]
|
||||||
// Helper to format status label
|
|
||||||
const formatStatusLabel = (status) => {
|
// Helper to format status label
|
||||||
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
const formatStatusLabel = (status) => {
|
||||||
}
|
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||||
|
}
|
||||||
// Helper to get status color
|
|
||||||
const getStatusTextColor = (status) => {
|
// Helper to get status color
|
||||||
const lowerStatus = status.toLowerCase()
|
const getStatusTextColor = (status) => {
|
||||||
if (lowerStatus === 'backlog') return 'text-gray-400'
|
const lowerStatus = status.toLowerCase()
|
||||||
if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'text-blue-400'
|
if (lowerStatus === 'backlog') return 'text-gray-400'
|
||||||
if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'text-yellow-400'
|
if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'text-blue-400'
|
||||||
if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'text-green-400'
|
if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'text-yellow-400'
|
||||||
if (lowerStatus.includes('blocked')) return 'text-red-400'
|
if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'text-green-400'
|
||||||
return 'text-purple-400' // default for custom statuses
|
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)
|
function TaskMenu({ task, onUpdate, onDelete, onEdit, projectStatuses }) {
|
||||||
const [showTimeEdit, setShowTimeEdit] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [showDescriptionEdit, setShowDescriptionEdit] = useState(false)
|
const [showTimeEdit, setShowTimeEdit] = useState(false)
|
||||||
const [showTagsEdit, setShowTagsEdit] = useState(false)
|
const [showDescriptionEdit, setShowDescriptionEdit] = useState(false)
|
||||||
const [showFlagEdit, setShowFlagEdit] = useState(false)
|
const [showTagsEdit, setShowTagsEdit] = useState(false)
|
||||||
const [showStatusEdit, setShowStatusEdit] = useState(false)
|
const [showFlagEdit, setShowFlagEdit] = useState(false)
|
||||||
|
const [showStatusEdit, setShowStatusEdit] = useState(false)
|
||||||
// Calculate hours and minutes from task.estimated_minutes
|
const [showBlockerPanel, setShowBlockerPanel] = useState(false)
|
||||||
const initialHours = task.estimated_minutes ? Math.floor(task.estimated_minutes / 60) : ''
|
|
||||||
const initialMinutes = task.estimated_minutes ? task.estimated_minutes % 60 : ''
|
// Calculate hours and minutes from task.estimated_minutes
|
||||||
|
const initialHours = task.estimated_minutes ? Math.floor(task.estimated_minutes / 60) : ''
|
||||||
const [editHours, setEditHours] = useState(initialHours)
|
const initialMinutes = task.estimated_minutes ? task.estimated_minutes % 60 : ''
|
||||||
const [editMinutes, setEditMinutes] = useState(initialMinutes)
|
|
||||||
const [editDescription, setEditDescription] = useState(task.description || '')
|
const [editHours, setEditHours] = useState(initialHours)
|
||||||
const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '')
|
const [editMinutes, setEditMinutes] = useState(initialMinutes)
|
||||||
const menuRef = useRef(null)
|
const [editDescription, setEditDescription] = useState(task.description || '')
|
||||||
|
const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '')
|
||||||
useEffect(() => {
|
const menuRef = useRef(null)
|
||||||
function handleClickOutside(event) {
|
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
useEffect(() => {
|
||||||
setIsOpen(false)
|
function handleClickOutside(event) {
|
||||||
setShowTimeEdit(false)
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
setShowDescriptionEdit(false)
|
setIsOpen(false)
|
||||||
setShowTagsEdit(false)
|
setShowTimeEdit(false)
|
||||||
setShowFlagEdit(false)
|
setShowDescriptionEdit(false)
|
||||||
setShowStatusEdit(false)
|
setShowTagsEdit(false)
|
||||||
}
|
setShowFlagEdit(false)
|
||||||
}
|
setShowStatusEdit(false)
|
||||||
|
}
|
||||||
if (isOpen) {
|
}
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
if (isOpen) {
|
||||||
}
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
}, [isOpen])
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
const handleUpdateTime = async () => {
|
}, [isOpen])
|
||||||
try {
|
|
||||||
const totalMinutes = (parseInt(editHours) || 0) * 60 + (parseInt(editMinutes) || 0)
|
const handleUpdateTime = async () => {
|
||||||
const minutes = totalMinutes > 0 ? totalMinutes : null
|
try {
|
||||||
await updateTask(task.id, { estimated_minutes: minutes })
|
const totalMinutes = (parseInt(editHours) || 0) * 60 + (parseInt(editMinutes) || 0)
|
||||||
setShowTimeEdit(false)
|
const minutes = totalMinutes > 0 ? totalMinutes : null
|
||||||
setIsOpen(false)
|
await updateTask(task.id, { estimated_minutes: minutes })
|
||||||
onUpdate()
|
setShowTimeEdit(false)
|
||||||
} catch (err) {
|
setIsOpen(false)
|
||||||
alert(`Error: ${err.message}`)
|
onUpdate()
|
||||||
}
|
} catch (err) {
|
||||||
}
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
const handleUpdateDescription = async () => {
|
}
|
||||||
try {
|
|
||||||
const description = editDescription.trim() || null
|
const handleUpdateDescription = async () => {
|
||||||
await updateTask(task.id, { description })
|
try {
|
||||||
setShowDescriptionEdit(false)
|
const description = editDescription.trim() || null
|
||||||
setIsOpen(false)
|
await updateTask(task.id, { description })
|
||||||
onUpdate()
|
setShowDescriptionEdit(false)
|
||||||
} catch (err) {
|
setIsOpen(false)
|
||||||
alert(`Error: ${err.message}`)
|
onUpdate()
|
||||||
}
|
} catch (err) {
|
||||||
}
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
const handleUpdateTags = async () => {
|
}
|
||||||
try {
|
|
||||||
const tags = editTags
|
const handleUpdateTags = async () => {
|
||||||
? editTags.split(',').map(t => t.trim()).filter(t => t.length > 0)
|
try {
|
||||||
: null
|
const tags = editTags
|
||||||
await updateTask(task.id, { tags })
|
? editTags.split(',').map(t => t.trim()).filter(t => t.length > 0)
|
||||||
setShowTagsEdit(false)
|
: null
|
||||||
setIsOpen(false)
|
await updateTask(task.id, { tags })
|
||||||
onUpdate()
|
setShowTagsEdit(false)
|
||||||
} catch (err) {
|
setIsOpen(false)
|
||||||
alert(`Error: ${err.message}`)
|
onUpdate()
|
||||||
}
|
} catch (err) {
|
||||||
}
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
const handleUpdateFlag = async (color) => {
|
}
|
||||||
try {
|
|
||||||
await updateTask(task.id, { flag_color: color })
|
const handleUpdateFlag = async (color) => {
|
||||||
setShowFlagEdit(false)
|
try {
|
||||||
setIsOpen(false)
|
await updateTask(task.id, { flag_color: color })
|
||||||
onUpdate()
|
setShowFlagEdit(false)
|
||||||
} catch (err) {
|
setIsOpen(false)
|
||||||
alert(`Error: ${err.message}`)
|
onUpdate()
|
||||||
}
|
} catch (err) {
|
||||||
}
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
const handleClearFlag = async () => {
|
}
|
||||||
try {
|
|
||||||
await updateTask(task.id, { flag_color: null })
|
const handleClearFlag = async () => {
|
||||||
setShowFlagEdit(false)
|
try {
|
||||||
setIsOpen(false)
|
await updateTask(task.id, { flag_color: null })
|
||||||
onUpdate()
|
setShowFlagEdit(false)
|
||||||
} catch (err) {
|
setIsOpen(false)
|
||||||
alert(`Error: ${err.message}`)
|
onUpdate()
|
||||||
}
|
} catch (err) {
|
||||||
}
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
const handleUpdateStatus = async (newStatus) => {
|
}
|
||||||
try {
|
|
||||||
await updateTask(task.id, { status: newStatus })
|
const handleUpdateStatus = async (newStatus) => {
|
||||||
setShowStatusEdit(false)
|
try {
|
||||||
setIsOpen(false)
|
await updateTask(task.id, { status: newStatus })
|
||||||
onUpdate()
|
setShowStatusEdit(false)
|
||||||
} catch (err) {
|
setIsOpen(false)
|
||||||
alert(`Error: ${err.message}`)
|
onUpdate()
|
||||||
}
|
} catch (err) {
|
||||||
}
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
return (
|
}
|
||||||
<div className="relative" ref={menuRef}>
|
|
||||||
<button
|
return (
|
||||||
onClick={(e) => {
|
<div className="relative" ref={menuRef}>
|
||||||
e.stopPropagation()
|
<button
|
||||||
setIsOpen(!isOpen)
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation()
|
||||||
className="text-gray-400 hover:text-gray-200 p-1"
|
setIsOpen(!isOpen)
|
||||||
title="More options"
|
}}
|
||||||
>
|
className="text-gray-400 hover:text-gray-200 p-1"
|
||||||
<MoreVertical size={16} />
|
title="More options"
|
||||||
</button>
|
>
|
||||||
|
<MoreVertical size={16} />
|
||||||
{isOpen && (
|
</button>
|
||||||
<div className="absolute right-0 top-8 z-50 w-64 bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-lg overflow-hidden">
|
|
||||||
{/* Time Edit */}
|
{isOpen && (
|
||||||
{showTimeEdit ? (
|
<div className="absolute right-0 top-8 z-50 w-64 bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-lg overflow-hidden">
|
||||||
<div className="p-3 border-b border-cyber-orange/20">
|
{/* Time Edit */}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
{showTimeEdit ? (
|
||||||
<Clock size={14} className="text-cyber-orange" />
|
<div className="p-3 border-b border-cyber-orange/20">
|
||||||
<span className="text-sm text-gray-300">Time Estimate</span>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
</div>
|
<Clock size={14} className="text-cyber-orange" />
|
||||||
<div className="flex gap-2 mb-2">
|
<span className="text-sm text-gray-300">Time Estimate</span>
|
||||||
<input
|
</div>
|
||||||
type="number"
|
<div className="flex gap-2 mb-2">
|
||||||
min="0"
|
<input
|
||||||
value={editHours}
|
type="number"
|
||||||
onChange={(e) => setEditHours(e.target.value)}
|
min="0"
|
||||||
placeholder="Hours"
|
value={editHours}
|
||||||
className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
onChange={(e) => setEditHours(e.target.value)}
|
||||||
autoFocus
|
placeholder="Hours"
|
||||||
onClick={(e) => e.stopPropagation()}
|
className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||||
/>
|
autoFocus
|
||||||
<input
|
onClick={(e) => e.stopPropagation()}
|
||||||
type="number"
|
/>
|
||||||
min="0"
|
<input
|
||||||
max="59"
|
type="number"
|
||||||
value={editMinutes}
|
min="0"
|
||||||
onChange={(e) => setEditMinutes(e.target.value)}
|
max="59"
|
||||||
placeholder="Minutes"
|
value={editMinutes}
|
||||||
className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
onChange={(e) => setEditMinutes(e.target.value)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
placeholder="Minutes"
|
||||||
/>
|
className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||||
</div>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<div className="flex gap-2">
|
/>
|
||||||
<button
|
</div>
|
||||||
onClick={handleUpdateTime}
|
<div className="flex gap-2">
|
||||||
className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30"
|
<button
|
||||||
>
|
onClick={handleUpdateTime}
|
||||||
Save
|
className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30"
|
||||||
</button>
|
>
|
||||||
<button
|
Save
|
||||||
onClick={() => setShowTimeEdit(false)}
|
</button>
|
||||||
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
|
<button
|
||||||
>
|
onClick={() => setShowTimeEdit(false)}
|
||||||
Cancel
|
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
|
||||||
</button>
|
>
|
||||||
</div>
|
Cancel
|
||||||
</div>
|
</button>
|
||||||
) : (
|
</div>
|
||||||
<button
|
</div>
|
||||||
onClick={(e) => {
|
) : (
|
||||||
e.stopPropagation()
|
<button
|
||||||
setShowTimeEdit(true)
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation()
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
setShowTimeEdit(true)
|
||||||
>
|
}}
|
||||||
<Clock size={14} />
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
<span>Set Time Estimate</span>
|
>
|
||||||
</button>
|
<Clock size={14} />
|
||||||
)}
|
<span>Set Time Estimate</span>
|
||||||
|
</button>
|
||||||
{/* Description Edit */}
|
)}
|
||||||
{showDescriptionEdit ? (
|
|
||||||
<div className="p-3 border-b border-cyber-orange/20">
|
{/* Description Edit */}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
{showDescriptionEdit ? (
|
||||||
<FileText size={14} className="text-cyber-orange" />
|
<div className="p-3 border-b border-cyber-orange/20">
|
||||||
<span className="text-sm text-gray-300">Description</span>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
</div>
|
<FileText size={14} className="text-cyber-orange" />
|
||||||
<div className="space-y-2">
|
<span className="text-sm text-gray-300">Description</span>
|
||||||
<textarea
|
</div>
|
||||||
value={editDescription}
|
<div className="space-y-2">
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
<textarea
|
||||||
placeholder="Task description..."
|
value={editDescription}
|
||||||
rows="4"
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
className="w-full px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange resize-y"
|
placeholder="Task description..."
|
||||||
autoFocus
|
rows="4"
|
||||||
onClick={(e) => e.stopPropagation()}
|
className="w-full px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange resize-y"
|
||||||
/>
|
autoFocus
|
||||||
<div className="flex gap-2">
|
onClick={(e) => e.stopPropagation()}
|
||||||
<button
|
/>
|
||||||
onClick={handleUpdateDescription}
|
<div className="flex gap-2">
|
||||||
className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30"
|
<button
|
||||||
>
|
onClick={handleUpdateDescription}
|
||||||
Save
|
className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30"
|
||||||
</button>
|
>
|
||||||
<button
|
Save
|
||||||
onClick={() => setShowDescriptionEdit(false)}
|
</button>
|
||||||
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
|
<button
|
||||||
>
|
onClick={() => setShowDescriptionEdit(false)}
|
||||||
Cancel
|
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
|
||||||
</button>
|
>
|
||||||
</div>
|
Cancel
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<button
|
</div>
|
||||||
onClick={(e) => {
|
) : (
|
||||||
e.stopPropagation()
|
<button
|
||||||
setShowDescriptionEdit(true)
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation()
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
setShowDescriptionEdit(true)
|
||||||
>
|
}}
|
||||||
<FileText size={14} />
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
<span>Edit Description</span>
|
>
|
||||||
</button>
|
<FileText size={14} />
|
||||||
)}
|
<span>Edit Description</span>
|
||||||
|
</button>
|
||||||
{/* Tags Edit */}
|
)}
|
||||||
{showTagsEdit ? (
|
|
||||||
<div className="p-3 border-b border-cyber-orange/20">
|
{/* Tags Edit */}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
{showTagsEdit ? (
|
||||||
<Tag size={14} className="text-cyber-orange" />
|
<div className="p-3 border-b border-cyber-orange/20">
|
||||||
<span className="text-sm text-gray-300">Tags (comma-separated)</span>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
</div>
|
<Tag size={14} className="text-cyber-orange" />
|
||||||
<div className="flex gap-2">
|
<span className="text-sm text-gray-300">Tags (comma-separated)</span>
|
||||||
<input
|
</div>
|
||||||
type="text"
|
<div className="flex gap-2">
|
||||||
value={editTags}
|
<input
|
||||||
onChange={(e) => setEditTags(e.target.value)}
|
type="text"
|
||||||
placeholder="coding, bug-fix"
|
value={editTags}
|
||||||
className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
onChange={(e) => setEditTags(e.target.value)}
|
||||||
autoFocus
|
placeholder="coding, bug-fix"
|
||||||
onClick={(e) => e.stopPropagation()}
|
className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||||
/>
|
autoFocus
|
||||||
<button
|
onClick={(e) => e.stopPropagation()}
|
||||||
onClick={handleUpdateTags}
|
/>
|
||||||
className="text-green-400 hover:text-green-300"
|
<button
|
||||||
>
|
onClick={handleUpdateTags}
|
||||||
<Check size={16} />
|
className="text-green-400 hover:text-green-300"
|
||||||
</button>
|
>
|
||||||
<button
|
<Check size={16} />
|
||||||
onClick={() => setShowTagsEdit(false)}
|
</button>
|
||||||
className="text-gray-400 hover:text-gray-300"
|
<button
|
||||||
>
|
onClick={() => setShowTagsEdit(false)}
|
||||||
<X size={16} />
|
className="text-gray-400 hover:text-gray-300"
|
||||||
</button>
|
>
|
||||||
</div>
|
<X size={16} />
|
||||||
</div>
|
</button>
|
||||||
) : (
|
</div>
|
||||||
<button
|
</div>
|
||||||
onClick={(e) => {
|
) : (
|
||||||
e.stopPropagation()
|
<button
|
||||||
setShowTagsEdit(true)
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation()
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
setShowTagsEdit(true)
|
||||||
>
|
}}
|
||||||
<Tag size={14} />
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
<span>Edit Tags</span>
|
>
|
||||||
</button>
|
<Tag size={14} />
|
||||||
)}
|
<span>Edit Tags</span>
|
||||||
|
</button>
|
||||||
{/* Flag Color Edit */}
|
)}
|
||||||
{showFlagEdit ? (
|
|
||||||
<div className="p-3 border-b border-cyber-orange/20">
|
{/* Flag Color Edit */}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
{showFlagEdit ? (
|
||||||
<Flag size={14} className="text-cyber-orange" />
|
<div className="p-3 border-b border-cyber-orange/20">
|
||||||
<span className="text-sm text-gray-300">Flag Color</span>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
</div>
|
<Flag size={14} className="text-cyber-orange" />
|
||||||
<div className="flex gap-2 flex-wrap">
|
<span className="text-sm text-gray-300">Flag Color</span>
|
||||||
{FLAG_COLORS.map(({ name, color }) => (
|
</div>
|
||||||
<button
|
<div className="flex gap-2 flex-wrap">
|
||||||
key={name}
|
{FLAG_COLORS.map(({ name, color }) => (
|
||||||
onClick={() => handleUpdateFlag(name)}
|
<button
|
||||||
className={`w-6 h-6 ${color} rounded hover:ring-2 hover:ring-cyber-orange transition-all`}
|
key={name}
|
||||||
title={name}
|
onClick={() => handleUpdateFlag(name)}
|
||||||
/>
|
className={`w-6 h-6 ${color} rounded hover:ring-2 hover:ring-cyber-orange transition-all`}
|
||||||
))}
|
title={name}
|
||||||
</div>
|
/>
|
||||||
<button
|
))}
|
||||||
onClick={handleClearFlag}
|
</div>
|
||||||
className="w-full mt-2 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 border border-gray-600 rounded"
|
<button
|
||||||
>
|
onClick={handleClearFlag}
|
||||||
Clear Flag
|
className="w-full mt-2 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 border border-gray-600 rounded"
|
||||||
</button>
|
>
|
||||||
</div>
|
Clear Flag
|
||||||
) : (
|
</button>
|
||||||
<button
|
</div>
|
||||||
onClick={(e) => {
|
) : (
|
||||||
e.stopPropagation()
|
<button
|
||||||
setShowFlagEdit(true)
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation()
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
setShowFlagEdit(true)
|
||||||
>
|
}}
|
||||||
<Flag size={14} />
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
<span>Set Flag Color</span>
|
>
|
||||||
</button>
|
<Flag size={14} />
|
||||||
)}
|
<span>Set Flag Color</span>
|
||||||
|
</button>
|
||||||
{/* Status Change */}
|
)}
|
||||||
{showStatusEdit ? (
|
|
||||||
<div className="p-3 border-b border-cyber-orange/20">
|
{/* Status Change */}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
{showStatusEdit ? (
|
||||||
<ListTodo size={14} className="text-cyber-orange" />
|
<div className="p-3 border-b border-cyber-orange/20">
|
||||||
<span className="text-sm text-gray-300">Change Status</span>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
</div>
|
<ListTodo size={14} className="text-cyber-orange" />
|
||||||
<div className="space-y-1">
|
<span className="text-sm text-gray-300">Change Status</span>
|
||||||
{(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map((status) => (
|
</div>
|
||||||
<button
|
<div className="space-y-1">
|
||||||
key={status}
|
{(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map((status) => (
|
||||||
onClick={() => handleUpdateStatus(status)}
|
<button
|
||||||
className={`w-full text-left px-2 py-1.5 rounded text-sm ${
|
key={status}
|
||||||
task.status === status
|
onClick={() => handleUpdateStatus(status)}
|
||||||
? 'bg-cyber-orange/20 border border-cyber-orange/40'
|
className={`w-full text-left px-2 py-1.5 rounded text-sm ${
|
||||||
: 'hover:bg-cyber-darker border border-transparent'
|
task.status === status
|
||||||
} ${getStatusTextColor(status)} transition-all`}
|
? 'bg-cyber-orange/20 border border-cyber-orange/40'
|
||||||
>
|
: 'hover:bg-cyber-darker border border-transparent'
|
||||||
{formatStatusLabel(status)} {task.status === status && '✓'}
|
} ${getStatusTextColor(status)} transition-all`}
|
||||||
</button>
|
>
|
||||||
))}
|
{formatStatusLabel(status)} {task.status === status && '✓'}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
))}
|
||||||
) : (
|
</div>
|
||||||
<button
|
</div>
|
||||||
onClick={(e) => {
|
) : (
|
||||||
e.stopPropagation()
|
<button
|
||||||
setShowStatusEdit(true)
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation()
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
setShowStatusEdit(true)
|
||||||
>
|
}}
|
||||||
<ListTodo size={14} />
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
<span>Change Status</span>
|
>
|
||||||
</button>
|
<ListTodo size={14} />
|
||||||
)}
|
<span>Change Status</span>
|
||||||
|
</button>
|
||||||
{/* Edit Title */}
|
)}
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
{/* Manage Blockers */}
|
||||||
e.stopPropagation()
|
<button
|
||||||
onEdit()
|
onClick={(e) => {
|
||||||
setIsOpen(false)
|
e.stopPropagation()
|
||||||
}}
|
setShowBlockerPanel(true)
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
setIsOpen(false)
|
||||||
>
|
}}
|
||||||
<Edit2 size={14} />
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
<span>Edit Title</span>
|
>
|
||||||
</button>
|
<Lock size={14} />
|
||||||
|
<span>Manage Blockers</span>
|
||||||
{/* Delete */}
|
</button>
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
{/* Edit Title */}
|
||||||
e.stopPropagation()
|
<button
|
||||||
onDelete()
|
onClick={(e) => {
|
||||||
setIsOpen(false)
|
e.stopPropagation()
|
||||||
}}
|
onEdit()
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-red-400 hover:text-red-300 text-sm border-t border-cyber-orange/20"
|
setIsOpen(false)
|
||||||
>
|
}}
|
||||||
<Trash2 size={14} />
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
<span>Delete Task</span>
|
>
|
||||||
</button>
|
<Edit2 size={14} />
|
||||||
</div>
|
<span>Edit Title</span>
|
||||||
)}
|
</button>
|
||||||
</div>
|
|
||||||
)
|
{/* Delete */}
|
||||||
}
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
export default TaskMenu
|
e.stopPropagation()
|
||||||
|
onDelete()
|
||||||
|
setIsOpen(false)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-red-400 hover:text-red-300 text-sm border-t border-cyber-orange/20"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
<span>Delete Task</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Blocker panel modal */}
|
||||||
|
{showBlockerPanel && (
|
||||||
|
<BlockerPanel
|
||||||
|
task={task}
|
||||||
|
onClose={() => setShowBlockerPanel(false)}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskMenu
|
||||||
|
|||||||
190
frontend/src/pages/ActionableView.jsx
Normal file
190
frontend/src/pages/ActionableView.jsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Zap, Clock, RefreshCw, ChevronRight, Check } from 'lucide-react'
|
||||||
|
import { getActionableTasks, updateTask } from '../utils/api'
|
||||||
|
|
||||||
|
const FLAG_DOT = {
|
||||||
|
red: 'bg-red-500',
|
||||||
|
orange: 'bg-orange-500',
|
||||||
|
yellow: 'bg-yellow-500',
|
||||||
|
green: 'bg-green-500',
|
||||||
|
blue: 'bg-blue-500',
|
||||||
|
purple: 'bg-purple-500',
|
||||||
|
pink: 'bg-pink-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (minutes) => {
|
||||||
|
if (!minutes) return null
|
||||||
|
const h = Math.floor(minutes / 60)
|
||||||
|
const m = minutes % 60
|
||||||
|
if (h && m) return `${h}h ${m}m`
|
||||||
|
if (h) return `${h}h`
|
||||||
|
return `${m}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatStatusLabel = (status) =>
|
||||||
|
status.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
|
||||||
|
|
||||||
|
const getStatusColor = (status) => {
|
||||||
|
const s = status.toLowerCase()
|
||||||
|
if (s === 'in_progress' || s.includes('progress')) return 'text-blue-400'
|
||||||
|
if (s === 'on_hold' || s.includes('hold')) return 'text-yellow-400'
|
||||||
|
return 'text-gray-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionableView() {
|
||||||
|
const [tasks, setTasks] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [completingId, setCompletingId] = useState(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTasks()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadTasks = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
const data = await getActionableTasks()
|
||||||
|
setTasks(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMarkDone = async (task) => {
|
||||||
|
try {
|
||||||
|
setCompletingId(task.id)
|
||||||
|
await updateTask(task.id, { status: 'done' })
|
||||||
|
// Remove from list and reload to surface newly unblocked tasks
|
||||||
|
setTasks(prev => prev.filter(t => t.id !== task.id))
|
||||||
|
// Reload after a short beat so the user sees the removal first
|
||||||
|
setTimeout(() => loadTasks(), 600)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setCompletingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by project
|
||||||
|
const byProject = tasks.reduce((acc, task) => {
|
||||||
|
const key = task.project_id
|
||||||
|
if (!acc[key]) acc[key] = { name: task.project_name, tasks: [] }
|
||||||
|
acc[key].tasks.push(task)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const projectGroups = Object.entries(byProject)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Zap size={24} className="text-cyber-orange" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-100">What can I do right now?</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
|
{loading ? '...' : `${tasks.length} task${tasks.length !== 1 ? 's' : ''} ready across all projects`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadTasks}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:text-gray-200 border border-cyber-orange/20 hover:border-cyber-orange/40 rounded transition-colors"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 px-4 py-3 bg-red-900/30 border border-red-500/40 rounded text-red-300 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && tasks.length === 0 && (
|
||||||
|
<div className="text-center py-20 text-gray-600">
|
||||||
|
<Zap size={40} className="mx-auto mb-4 opacity-30" />
|
||||||
|
<p className="text-lg">Nothing actionable right now.</p>
|
||||||
|
<p className="text-sm mt-1">All tasks are either done or blocked.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Project groups */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
{projectGroups.map(([projectId, group]) => (
|
||||||
|
<div key={projectId}>
|
||||||
|
{/* Project header */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/project/${projectId}`)}
|
||||||
|
className="flex items-center gap-2 mb-3 text-cyber-orange hover:text-cyber-orange-bright transition-colors group"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold uppercase tracking-wider">{group.name}</span>
|
||||||
|
<ChevronRight size={14} className="opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Task cards */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{group.tasks.map(task => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 bg-cyber-darkest border border-cyber-orange/20 rounded-lg hover:border-cyber-orange/40 transition-all ${
|
||||||
|
completingId === task.id ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Done button */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleMarkDone(task)}
|
||||||
|
disabled={completingId === task.id}
|
||||||
|
className="flex-shrink-0 w-6 h-6 rounded-full border-2 border-cyber-orange/40 hover:border-green-400 hover:bg-green-400/10 flex items-center justify-center transition-all group"
|
||||||
|
title="Mark as done"
|
||||||
|
>
|
||||||
|
<Check size={12} className="text-transparent group-hover:text-green-400 transition-colors" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Flag dot */}
|
||||||
|
{task.flag_color && (
|
||||||
|
<span className={`flex-shrink-0 w-2 h-2 rounded-full ${FLAG_DOT[task.flag_color] || 'bg-gray-500'}`} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title + meta */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm text-gray-100">{task.title}</span>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
||||||
|
{task.status !== 'backlog' && (
|
||||||
|
<span className={`text-xs ${getStatusColor(task.status)}`}>
|
||||||
|
{formatStatusLabel(task.status)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.estimated_minutes && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-gray-600">
|
||||||
|
<Clock size={10} />
|
||||||
|
{formatTime(task.estimated_minutes)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.tags && task.tags.map(tag => (
|
||||||
|
<span key={tag} className="text-xs text-cyber-orange/60 bg-cyber-orange/5 px-1.5 py-0.5 rounded">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ActionableView
|
||||||
@@ -1,76 +1,85 @@
|
|||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
|
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||||
|
|
||||||
async function fetchAPI(endpoint, options = {}) {
|
async function fetchAPI(endpoint, options = {}) {
|
||||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
|
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
|
||||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
export const getProjects = (archived = null) => {
|
export const getProjects = (archived = null) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (archived !== null) {
|
if (archived !== null) {
|
||||||
params.append('archived', archived);
|
params.append('archived', archived);
|
||||||
}
|
}
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
return fetchAPI(`/projects${queryString ? `?${queryString}` : ''}`);
|
return fetchAPI(`/projects${queryString ? `?${queryString}` : ''}`);
|
||||||
};
|
};
|
||||||
export const getProject = (id) => fetchAPI(`/projects/${id}`);
|
export const getProject = (id) => fetchAPI(`/projects/${id}`);
|
||||||
export const createProject = (data) => fetchAPI('/projects', {
|
export const createProject = (data) => fetchAPI('/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
export const updateProject = (id, data) => fetchAPI(`/projects/${id}`, {
|
export const updateProject = (id, data) => fetchAPI(`/projects/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
export const deleteProject = (id) => fetchAPI(`/projects/${id}`, { method: 'DELETE' });
|
export const deleteProject = (id) => fetchAPI(`/projects/${id}`, { method: 'DELETE' });
|
||||||
export const archiveProject = (id) => updateProject(id, { is_archived: true });
|
export const archiveProject = (id) => updateProject(id, { is_archived: true });
|
||||||
export const unarchiveProject = (id) => updateProject(id, { is_archived: false });
|
export const unarchiveProject = (id) => updateProject(id, { is_archived: false });
|
||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
export const getProjectTasks = (projectId) => fetchAPI(`/projects/${projectId}/tasks`);
|
export const getProjectTasks = (projectId) => fetchAPI(`/projects/${projectId}/tasks`);
|
||||||
export const getProjectTaskTree = (projectId) => fetchAPI(`/projects/${projectId}/tasks/tree`);
|
export const getProjectTaskTree = (projectId) => fetchAPI(`/projects/${projectId}/tasks/tree`);
|
||||||
export const getTasksByStatus = (projectId, status) =>
|
export const getTasksByStatus = (projectId, status) =>
|
||||||
fetchAPI(`/projects/${projectId}/tasks/by-status/${status}`);
|
fetchAPI(`/projects/${projectId}/tasks/by-status/${status}`);
|
||||||
|
|
||||||
export const getTask = (id) => fetchAPI(`/tasks/${id}`);
|
export const getTask = (id) => fetchAPI(`/tasks/${id}`);
|
||||||
export const createTask = (data) => fetchAPI('/tasks', {
|
export const createTask = (data) => fetchAPI('/tasks', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
export const updateTask = (id, data) => fetchAPI(`/tasks/${id}`, {
|
export const updateTask = (id, data) => fetchAPI(`/tasks/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
export const deleteTask = (id) => fetchAPI(`/tasks/${id}`, { method: 'DELETE' });
|
export const deleteTask = (id) => fetchAPI(`/tasks/${id}`, { method: 'DELETE' });
|
||||||
|
|
||||||
// JSON Import
|
// JSON Import
|
||||||
export const importJSON = (data) => fetchAPI('/import-json', {
|
export const importJSON = (data) => fetchAPI('/import-json', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search
|
// Blockers
|
||||||
export const searchTasks = (query, projectIds = null) => {
|
export const getTaskBlockers = (taskId) => fetchAPI(`/tasks/${taskId}/blockers`);
|
||||||
const params = new URLSearchParams({ query });
|
export const getTaskBlocking = (taskId) => fetchAPI(`/tasks/${taskId}/blocking`);
|
||||||
if (projectIds && projectIds.length > 0) {
|
export const addBlocker = (taskId, blockerId) => fetchAPI(`/tasks/${taskId}/blockers/${blockerId}`, { method: 'POST' });
|
||||||
params.append('project_ids', projectIds.join(','));
|
export const removeBlocker = (taskId, blockerId) => fetchAPI(`/tasks/${taskId}/blockers/${blockerId}`, { method: 'DELETE' });
|
||||||
}
|
|
||||||
return fetchAPI(`/search?${params.toString()}`);
|
// Actionable tasks (no incomplete blockers, not done)
|
||||||
};
|
export const getActionableTasks = () => fetchAPI('/actionable');
|
||||||
|
|
||||||
|
// Search
|
||||||
|
export const searchTasks = (query, projectIds = null) => {
|
||||||
|
const params = new URLSearchParams({ query });
|
||||||
|
if (projectIds && projectIds.length > 0) {
|
||||||
|
params.append('project_ids', projectIds.join(','));
|
||||||
|
}
|
||||||
|
return fetchAPI(`/search?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user