v0.1.6 changes

This commit is contained in:
serversdwn
2025-11-25 23:22:44 +00:00
parent 8d5ad6a809
commit 1a6c8cf98c
12 changed files with 650 additions and 101 deletions

View File

@@ -5,7 +5,12 @@ from . import models, schemas
# Project CRUD
def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project:
db_project = models.Project(**project.model_dump())
project_data = project.model_dump()
# Ensure statuses has a default value if not provided
if project_data.get("statuses") is None:
project_data["statuses"] = models.DEFAULT_STATUSES
db_project = models.Project(**project_data)
db.add(db_project)
db.commit()
db.refresh(db_project)
@@ -47,6 +52,11 @@ def delete_project(db: Session, project_id: int) -> bool:
# Task CRUD
def create_task(db: Session, task: schemas.TaskCreate) -> models.Task:
# Validate status against project's statuses
project = get_project(db, task.project_id)
if project and task.status not in project.statuses:
raise ValueError(f"Invalid status '{task.status}'. Must be one of: {', '.join(project.statuses)}")
# Get max sort_order for siblings
if task.parent_task_id:
max_order = db.query(models.Task).filter(
@@ -104,13 +114,13 @@ def check_and_update_parent_status(db: Session, parent_id: int):
return
# Check if all children are done
all_done = all(child.status == models.TaskStatus.DONE for child in children)
all_done = all(child.status == "done" for child in children)
if all_done:
# Mark parent as done
parent = get_task(db, parent_id)
if parent and parent.status != models.TaskStatus.DONE:
parent.status = models.TaskStatus.DONE
if parent and parent.status != "done":
parent.status = "done"
db.commit()
# Recursively check grandparent
@@ -126,12 +136,16 @@ def update_task(
return None
update_data = task.model_dump(exclude_unset=True)
status_changed = False
# Check if status is being updated
# Validate status against project's statuses if status is being updated
if "status" in update_data:
project = get_project(db, db_task.project_id)
if project and update_data["status"] not in project.statuses:
raise ValueError(f"Invalid status '{update_data['status']}'. Must be one of: {', '.join(project.statuses)}")
status_changed = True
old_status = db_task.status
else:
status_changed = False
for key, value in update_data.items():
setattr(db_task, key, value)
@@ -140,7 +154,7 @@ def update_task(
db.refresh(db_task)
# If status changed to 'done' and this task has a parent, check if parent should auto-complete
if status_changed and db_task.status == models.TaskStatus.DONE and db_task.parent_task_id:
if status_changed and db_task.status == "done" and db_task.parent_task_id:
check_and_update_parent_status(db, db_task.parent_task_id)
return db_task
@@ -155,8 +169,13 @@ def delete_task(db: Session, task_id: int) -> bool:
return True
def get_tasks_by_status(db: Session, project_id: int, status: models.TaskStatus) -> List[models.Task]:
def get_tasks_by_status(db: Session, project_id: int, status: str) -> List[models.Task]:
"""Get all tasks for a project with a specific status"""
# Validate status against project's statuses
project = get_project(db, project_id)
if project and status not in project.statuses:
raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(project.statuses)}")
return db.query(models.Task).filter(
models.Task.project_id == project_id,
models.Task.status == status

View File

@@ -98,13 +98,16 @@ def get_project_task_tree(project_id: int, db: Session = Depends(get_db)):
@app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task])
def get_tasks_by_status(
project_id: int,
status: models.TaskStatus,
status: str,
db: Session = Depends(get_db)
):
"""Get all tasks for a project filtered by status (for Kanban view)"""
if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
try:
return crud.get_tasks_by_status(db, project_id, status)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/tasks", response_model=schemas.Task, status_code=201)
@@ -116,7 +119,10 @@ def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
if task.parent_task_id and not crud.get_task(db, task.parent_task_id):
raise HTTPException(status_code=404, detail="Parent task not found")
try:
return crud.create_task(db, task)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/tasks/{task_id}", response_model=schemas.Task)
@@ -131,10 +137,13 @@ def get_task(task_id: int, db: Session = Depends(get_db)):
@app.put("/api/tasks/{task_id}", response_model=schemas.Task)
def update_task(task_id: int, task: schemas.TaskUpdate, db: Session = Depends(get_db)):
"""Update a task"""
try:
db_task = crud.update_task(db, task_id, task)
if not db_task:
raise HTTPException(status_code=404, detail="Task not found")
return db_task
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.delete("/api/tasks/{task_id}", status_code=204)
@@ -188,6 +197,27 @@ def search_tasks(
# ========== JSON IMPORT ENDPOINT ==========
def _validate_task_statuses_recursive(
tasks: List[schemas.ImportSubtask],
valid_statuses: List[str],
path: str = ""
) -> None:
"""Recursively validate all task statuses against the project's valid statuses"""
for idx, task_data in enumerate(tasks):
task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]"
if task_data.status not in valid_statuses:
raise ValueError(
f"Invalid status '{task_data.status}' at {task_path}. "
f"Must be one of: {', '.join(valid_statuses)}"
)
if task_data.subtasks:
_validate_task_statuses_recursive(
task_data.subtasks,
valid_statuses,
f"{task_path}.subtasks"
)
def _import_tasks_recursive(
db: Session,
project_id: int,
@@ -228,7 +258,8 @@ def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_
{
"project": {
"name": "Project Name",
"description": "Optional description"
"description": "Optional description",
"statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional
},
"tasks": [
{
@@ -246,15 +277,26 @@ def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_
]
}
"""
# Create the project
# Create the project with optional statuses
project = crud.create_project(
db,
schemas.ProjectCreate(
name=import_data.project.name,
description=import_data.project.description
description=import_data.project.description,
statuses=import_data.project.statuses
)
)
# Validate all task statuses before importing
if import_data.tasks:
try:
_validate_task_statuses_recursive(import_data.tasks, project.statuses)
except ValueError as e:
# Rollback the project creation if validation fails
db.delete(project)
db.commit()
raise HTTPException(status_code=400, detail=str(e))
# Recursively import tasks
tasks_created = _import_tasks_recursive(
db, project.id, import_data.tasks

View File

@@ -1,15 +1,11 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, JSON
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from .database import Base
class TaskStatus(str, enum.Enum):
BACKLOG = "backlog"
IN_PROGRESS = "in_progress"
BLOCKED = "blocked"
DONE = "done"
# Default statuses for new projects
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
class Project(Base):
@@ -18,6 +14,7 @@ class Project(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -32,7 +29,7 @@ class Task(Base):
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
title = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
status = Column(Enum(TaskStatus), default=TaskStatus.BACKLOG, nullable=False)
status = Column(String(50), default="backlog", nullable=False)
sort_order = Column(Integer, default=0)
estimated_minutes = Column(Integer, nullable=True)
tags = Column(JSON, nullable=True)

View File

@@ -1,14 +1,14 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from datetime import datetime
from .models import TaskStatus
from .models import DEFAULT_STATUSES
# Task Schemas
class TaskBase(BaseModel):
title: str
description: Optional[str] = None
status: TaskStatus = TaskStatus.BACKLOG
status: str = "backlog"
parent_task_id: Optional[int] = None
sort_order: int = 0
estimated_minutes: Optional[int] = None
@@ -23,7 +23,7 @@ class TaskCreate(TaskBase):
class TaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[TaskStatus] = None
status: Optional[str] = None
parent_task_id: Optional[int] = None
sort_order: Optional[int] = None
estimated_minutes: Optional[int] = None
@@ -53,16 +53,18 @@ class ProjectBase(BaseModel):
class ProjectCreate(ProjectBase):
pass
statuses: Optional[List[str]] = None
class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
statuses: Optional[List[str]] = None
class Project(ProjectBase):
id: int
statuses: List[str]
created_at: datetime
updated_at: datetime
@@ -79,7 +81,7 @@ class ProjectWithTasks(Project):
class ImportSubtask(BaseModel):
title: str
description: Optional[str] = None
status: TaskStatus = TaskStatus.BACKLOG
status: str = "backlog"
estimated_minutes: Optional[int] = None
tags: Optional[List[str]] = None
flag_color: Optional[str] = None
@@ -89,6 +91,7 @@ class ImportSubtask(BaseModel):
class ImportProject(BaseModel):
name: str
description: Optional[str] = None
statuses: Optional[List[str]] = None
class ImportData(BaseModel):

View File

@@ -0,0 +1,62 @@
"""
Migration script to add statuses column to projects table
Run this script once to update existing database
"""
import sqlite3
import json
# Default statuses for existing projects
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
def migrate():
# Connect to the database
conn = sqlite3.connect('tesseract.db')
cursor = conn.cursor()
try:
# Check if statuses column already exists
cursor.execute("PRAGMA table_info(projects)")
columns = [column[1] for column in cursor.fetchall()]
if 'statuses' in columns:
print("✓ Column 'statuses' already exists in projects table")
return
# Add statuses column with default value
print("Adding 'statuses' column to projects table...")
cursor.execute("""
ALTER TABLE projects
ADD COLUMN statuses TEXT NOT NULL DEFAULT ?
""", (json.dumps(DEFAULT_STATUSES),))
# Update all existing projects with default statuses
cursor.execute("""
UPDATE projects
SET statuses = ?
WHERE statuses IS NULL OR statuses = ''
""", (json.dumps(DEFAULT_STATUSES),))
conn.commit()
print("✓ Successfully added 'statuses' column to projects table")
print(f"✓ Set default statuses for all existing projects: {DEFAULT_STATUSES}")
# Show count of updated projects
cursor.execute("SELECT COUNT(*) FROM projects")
count = cursor.fetchone()[0]
print(f"✓ Updated {count} project(s)")
except sqlite3.Error as e:
print(f"✗ Error during migration: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
print("=" * 60)
print("Database Migration: Add statuses column to projects")
print("=" * 60)
migrate()
print("=" * 60)
print("Migration completed!")
print("=" * 60)

View File

@@ -13,7 +13,7 @@ function App() {
<h1 className="text-2xl font-bold text-cyber-orange">
TESSERACT
<span className="ml-3 text-sm text-gray-500">Task Decomposition Engine</span>
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.5'}</span>
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
</h1>
</div>
<SearchBar />

View File

@@ -10,12 +10,21 @@ import { formatTimeWithTotal } from '../utils/format'
import TaskMenu from './TaskMenu'
import TaskForm from './TaskForm'
const STATUSES = [
{ key: 'backlog', label: 'Backlog', color: 'border-gray-600' },
{ key: 'in_progress', label: 'In Progress', color: 'border-blue-500' },
{ key: 'blocked', label: 'Blocked', color: 'border-red-500' },
{ key: 'done', label: 'Done', color: 'border-green-500' }
]
// Helper to format status label
const formatStatusLabel = (status) => {
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
// Helper to get status color based on common patterns
const getStatusColor = (status) => {
const lowerStatus = status.toLowerCase()
if (lowerStatus === 'backlog') return 'border-gray-600'
if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'border-blue-500'
if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'border-yellow-500'
if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'border-green-500'
if (lowerStatus.includes('blocked')) return 'border-red-500'
return 'border-purple-500' // default for custom statuses
}
const FLAG_COLORS = {
red: 'bg-red-500',
@@ -60,9 +69,10 @@ function hasDescendantsInStatus(taskId, allTasks, status) {
return getDescendantsInStatus(taskId, allTasks, status).length > 0
}
function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards }) {
function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards, projectStatuses, projectId }) {
const [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(task.title)
const [showAddSubtask, setShowAddSubtask] = useState(false)
// Use global expanded state
const isExpanded = expandedCards[task.id] || false
@@ -93,6 +103,26 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
}
}
const handleAddSubtask = async (taskData) => {
try {
await createTask({
project_id: parseInt(projectId),
parent_task_id: task.id,
title: taskData.title,
description: taskData.description,
status: taskData.status,
tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color
})
setShowAddSubtask(false)
setExpandedCards(prev => ({ ...prev, [task.id]: true }))
onUpdate()
} catch (err) {
alert(`Error: ${err.message}`)
}
}
// For parent cards, get children in this column's status
const childrenInColumn = isParent ? getDescendantsInStatus(task.id, allTasks, columnStatus) : []
const totalChildren = isParent ? allTasks.filter(t => t.parent_task_id === task.id).length : 0
@@ -204,11 +234,19 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => setShowAddSubtask(true)}
className="text-cyber-orange hover:text-cyber-orange-bright p-1"
title="Add subtask"
>
<Plus size={14} />
</button>
<TaskMenu
task={task}
onUpdate={onUpdate}
onDelete={handleDelete}
onEdit={() => setIsEditing(true)}
projectStatuses={projectStatuses}
/>
</div>
</div>
@@ -216,6 +254,19 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
)}
</div>
{/* Add Subtask Form */}
{showAddSubtask && (
<div className="ml-6 mt-2">
<TaskForm
onSubmit={handleAddSubtask}
onCancel={() => setShowAddSubtask(false)}
submitLabel="Add Subtask"
projectStatuses={projectStatuses}
defaultStatus={columnStatus}
/>
</div>
)}
{/* Expanded children */}
{isParent && isExpanded && childrenInColumn.length > 0 && (
<div className="ml-6 mt-2 space-y-2">
@@ -230,6 +281,8 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
columnStatus={columnStatus}
expandedCards={expandedCards}
setExpandedCards={setExpandedCards}
projectStatuses={projectStatuses}
projectId={projectId}
/>
))}
</div>
@@ -238,7 +291,7 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
)
}
function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards }) {
function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards, projectStatuses }) {
const [showAddTask, setShowAddTask] = useState(false)
const handleAddTask = async (taskData) => {
@@ -248,7 +301,7 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
parent_task_id: null,
title: taskData.title,
description: taskData.description,
status: status.key,
status: taskData.status,
tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color
@@ -306,6 +359,8 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
onSubmit={handleAddTask}
onCancel={() => setShowAddTask(false)}
submitLabel="Add Task"
projectStatuses={projectStatuses}
defaultStatus={status.key}
/>
</div>
)}
@@ -327,6 +382,8 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
columnStatus={status.key}
expandedCards={expandedCards}
setExpandedCards={setExpandedCards}
projectStatuses={projectStatuses}
projectId={projectId}
/>
)
})}
@@ -335,12 +392,20 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
)
}
function KanbanView({ projectId }) {
function KanbanView({ projectId, project }) {
const [allTasks, setAllTasks] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [expandedCards, setExpandedCards] = useState({})
// Get statuses from project, or use defaults
const statuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
const statusesWithMeta = statuses.map(status => ({
key: status,
label: formatStatusLabel(status),
color: getStatusColor(status)
}))
useEffect(() => {
loadTasks()
}, [projectId])
@@ -430,7 +495,7 @@ function KanbanView({ projectId }) {
</div>
<div className="flex gap-4 overflow-x-auto pb-4">
{STATUSES.map(status => (
{statusesWithMeta.map(status => (
<KanbanColumn
key={status.key}
status={status}
@@ -441,6 +506,7 @@ function KanbanView({ projectId }) {
onDragOver={handleDragOver}
expandedCards={expandedCards}
setExpandedCards={setExpandedCards}
projectStatuses={statuses}
/>
))}
</div>

View File

@@ -0,0 +1,298 @@
import { useState, useEffect } from 'react'
import { X, GripVertical, Plus, Trash2, Check, AlertTriangle } from 'lucide-react'
import { updateProject, getProjectTasks } from '../utils/api'
function ProjectSettings({ project, onClose, onUpdate }) {
const [statuses, setStatuses] = useState(project.statuses || [])
const [editingIndex, setEditingIndex] = useState(null)
const [editingValue, setEditingValue] = useState('')
const [draggedIndex, setDraggedIndex] = useState(null)
const [error, setError] = useState('')
const [taskCounts, setTaskCounts] = useState({})
const [deleteWarning, setDeleteWarning] = useState(null)
useEffect(() => {
loadTaskCounts()
}, [])
const loadTaskCounts = async () => {
try {
const tasks = await getProjectTasks(project.id)
const counts = {}
statuses.forEach(status => {
counts[status] = tasks.filter(t => t.status === status).length
})
setTaskCounts(counts)
} catch (err) {
console.error('Failed to load task counts:', err)
}
}
const handleDragStart = (index) => {
setDraggedIndex(index)
}
const handleDragOver = (e, index) => {
e.preventDefault()
if (draggedIndex === null || draggedIndex === index) return
const newStatuses = [...statuses]
const draggedItem = newStatuses[draggedIndex]
newStatuses.splice(draggedIndex, 1)
newStatuses.splice(index, 0, draggedItem)
setStatuses(newStatuses)
setDraggedIndex(index)
}
const handleDragEnd = () => {
setDraggedIndex(null)
}
const handleAddStatus = () => {
const newStatus = `new_status_${Date.now()}`
setStatuses([...statuses, newStatus])
setEditingIndex(statuses.length)
setEditingValue(newStatus)
}
const handleStartEdit = (index) => {
setEditingIndex(index)
setEditingValue(statuses[index])
}
const handleSaveEdit = () => {
if (!editingValue.trim()) {
setError('Status name cannot be empty')
return
}
const trimmedValue = editingValue.trim().toLowerCase().replace(/\s+/g, '_')
if (statuses.some((s, i) => i !== editingIndex && s === trimmedValue)) {
setError('Status name already exists')
return
}
const newStatuses = [...statuses]
newStatuses[editingIndex] = trimmedValue
setStatuses(newStatuses)
setEditingIndex(null)
setError('')
}
const handleCancelEdit = () => {
// If it's a new status that was never saved, remove it
if (statuses[editingIndex].startsWith('new_status_')) {
const newStatuses = statuses.filter((_, i) => i !== editingIndex)
setStatuses(newStatuses)
}
setEditingIndex(null)
setError('')
}
const handleDeleteStatus = (index) => {
const statusToDelete = statuses[index]
const taskCount = taskCounts[statusToDelete] || 0
if (taskCount > 0) {
setDeleteWarning({ index, status: statusToDelete, count: taskCount })
return
}
if (statuses.length === 1) {
setError('Cannot delete the last status')
return
}
const newStatuses = statuses.filter((_, i) => i !== index)
setStatuses(newStatuses)
}
const handleSave = async () => {
if (statuses.length === 0) {
setError('Project must have at least one status')
return
}
if (editingIndex !== null) {
setError('Please save or cancel the status you are editing')
return
}
try {
await updateProject(project.id, { statuses })
onUpdate()
onClose()
} catch (err) {
setError(err.message)
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex justify-between items-center p-6 border-b border-cyber-orange/20">
<h2 className="text-2xl font-bold text-gray-100">Project Settings</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-200"
>
<X size={24} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-200 mb-2">Project Details</h3>
<div className="space-y-2">
<div>
<span className="text-sm text-gray-400">Name:</span>
<span className="ml-2 text-gray-200">{project.name}</span>
</div>
{project.description && (
<div>
<span className="text-sm text-gray-400">Description:</span>
<span className="ml-2 text-gray-200">{project.description}</span>
</div>
)}
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-200 mb-2">Status Workflow</h3>
<p className="text-sm text-gray-400 mb-4">
Drag to reorder, click to rename. Tasks will appear in Kanban columns in this order.
</p>
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm">
{error}
</div>
)}
<div className="space-y-2 mb-4">
{statuses.map((status, index) => (
<div
key={index}
draggable={editingIndex !== index}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
className={`flex items-center gap-3 p-3 bg-cyber-darker border border-cyber-orange/30 rounded ${
draggedIndex === index ? 'opacity-50' : ''
} ${editingIndex !== index ? 'cursor-move' : ''}`}
>
{editingIndex !== index && (
<GripVertical size={18} className="text-gray-500" />
)}
{editingIndex === index ? (
<>
<input
type="text"
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit()
if (e.key === 'Escape') handleCancelEdit()
}}
className="flex-1 px-2 py-1 bg-cyber-darkest border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
autoFocus
/>
<button
onClick={handleSaveEdit}
className="text-green-400 hover:text-green-300"
>
<Check size={18} />
</button>
<button
onClick={handleCancelEdit}
className="text-gray-400 hover:text-gray-300"
>
<X size={18} />
</button>
</>
) : (
<>
<button
onClick={() => handleStartEdit(index)}
className="flex-1 text-left text-gray-200 hover:text-cyber-orange"
>
{status.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}
</button>
<span className="text-xs text-gray-500">
{taskCounts[status] || 0} task{taskCounts[status] === 1 ? '' : 's'}
</span>
<button
onClick={() => handleDeleteStatus(index)}
className="text-gray-400 hover:text-red-400"
disabled={statuses.length === 1}
>
<Trash2 size={18} />
</button>
</>
)}
</div>
))}
</div>
<button
onClick={handleAddStatus}
className="flex items-center gap-2 px-4 py-2 text-sm bg-cyber-darker border border-cyber-orange/30 text-gray-300 rounded hover:border-cyber-orange/60 hover:bg-cyber-dark transition-colors"
>
<Plus size={16} />
Add Status
</button>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-6 border-t border-cyber-orange/20">
<button
onClick={onClose}
className="px-4 py-2 text-gray-400 hover:text-gray-200"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold transition-colors"
>
Save Changes
</button>
</div>
{/* Delete Warning Dialog */}
{deleteWarning && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="bg-cyber-darkest border border-red-500/50 rounded-lg p-6 max-w-md">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle className="text-red-400 flex-shrink-0" size={24} />
<div>
<h3 className="text-lg font-semibold text-gray-100 mb-2">Cannot Delete Status</h3>
<p className="text-sm text-gray-300">
The status "{deleteWarning.status}" has {deleteWarning.count} task{deleteWarning.count === 1 ? '' : 's'}.
Please move or delete those tasks first.
</p>
</div>
</div>
<div className="flex justify-end">
<button
onClick={() => setDeleteWarning(null)}
className="px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold"
>
OK
</button>
</div>
</div>
</div>
)}
</div>
</div>
)
}
export default ProjectSettings

View File

@@ -12,13 +12,22 @@ const FLAG_COLORS = [
{ name: 'pink', label: 'Pink', color: 'bg-pink-500' }
]
function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
// Helper to format status label
const formatStatusLabel = (status) => {
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
function TaskForm({ onSubmit, onCancel, submitLabel = "Add", projectStatuses = null, defaultStatus = "backlog" }) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [tags, setTags] = useState('')
const [hours, setHours] = useState('')
const [minutes, setMinutes] = useState('')
const [flagColor, setFlagColor] = useState(null)
const [status, setStatus] = useState(defaultStatus)
// Use provided statuses or fall back to defaults
const statuses = projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']
const handleSubmit = (e) => {
e.preventDefault()
@@ -37,7 +46,8 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
description: description.trim() || null,
tags: tagList && tagList.length > 0 ? tagList : null,
estimated_minutes: totalMinutes > 0 ? totalMinutes : null,
flag_color: flagColor
flag_color: flagColor,
status: status
}
onSubmit(taskData)
@@ -110,6 +120,22 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
</div>
</div>
{/* Status */}
<div>
<label className="block text-xs text-gray-400 mb-1">Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
>
{statuses.map((s) => (
<option key={s} value={s}>
{formatStatusLabel(s)}
</option>
))}
</select>
</div>
{/* Flag Color */}
<div>
<label className="block text-xs text-gray-400 mb-1">Flag Color</label>

View File

@@ -12,14 +12,23 @@ const FLAG_COLORS = [
{ name: 'pink', color: 'bg-pink-500' }
]
const STATUSES = [
{ key: 'backlog', label: 'Backlog', color: 'text-gray-400' },
{ key: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
{ key: 'blocked', label: 'Blocked', color: 'text-red-400' },
{ key: 'done', label: 'Done', color: 'text-green-400' }
]
// Helper to format status label
const formatStatusLabel = (status) => {
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
// Helper to get status color
const getStatusTextColor = (status) => {
const lowerStatus = status.toLowerCase()
if (lowerStatus === 'backlog') return 'text-gray-400'
if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'text-blue-400'
if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'text-yellow-400'
if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'text-green-400'
if (lowerStatus.includes('blocked')) return 'text-red-400'
return 'text-purple-400' // default for custom statuses
}
function TaskMenu({ task, onUpdate, onDelete, onEdit, projectStatuses }) {
const [isOpen, setIsOpen] = useState(false)
const [showTimeEdit, setShowTimeEdit] = useState(false)
const [showDescriptionEdit, setShowDescriptionEdit] = useState(false)
@@ -334,17 +343,17 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
<span className="text-sm text-gray-300">Change Status</span>
</div>
<div className="space-y-1">
{STATUSES.map(({ key, label, color }) => (
{(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map((status) => (
<button
key={key}
onClick={() => handleUpdateStatus(key)}
key={status}
onClick={() => handleUpdateStatus(status)}
className={`w-full text-left px-2 py-1.5 rounded text-sm ${
task.status === key
task.status === status
? 'bg-cyber-orange/20 border border-cyber-orange/40'
: 'hover:bg-cyber-darker border border-transparent'
} ${color} transition-all`}
} ${getStatusTextColor(status)} transition-all`}
>
{label} {task.status === key && '✓'}
{formatStatusLabel(status)} {task.status === status && '✓'}
</button>
))}
</div>

View File

@@ -18,18 +18,20 @@ import { formatTimeWithTotal } from '../utils/format'
import TaskMenu from './TaskMenu'
import TaskForm from './TaskForm'
const STATUS_COLORS = {
backlog: 'text-gray-400',
in_progress: 'text-blue-400',
blocked: 'text-red-400',
done: 'text-green-400'
// Helper to format status label
const formatStatusLabel = (status) => {
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
const STATUS_LABELS = {
backlog: 'Backlog',
in_progress: 'In Progress',
blocked: 'Blocked',
done: 'Done'
// Helper to get status color
const getStatusColor = (status) => {
const lowerStatus = status.toLowerCase()
if (lowerStatus === 'backlog') return 'text-gray-400'
if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'text-blue-400'
if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'text-yellow-400'
if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'text-green-400'
if (lowerStatus.includes('blocked')) return 'text-red-400'
return 'text-purple-400' // default for custom statuses
}
const FLAG_COLORS = {
@@ -42,7 +44,7 @@ const FLAG_COLORS = {
pink: 'bg-pink-500'
}
function TaskNode({ task, projectId, onUpdate, level = 0 }) {
function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
const [isExpanded, setIsExpanded] = useState(true)
const [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(task.title)
@@ -81,7 +83,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
parent_task_id: task.id,
title: taskData.title,
description: taskData.description,
status: 'backlog',
status: taskData.status,
tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color
@@ -126,10 +128,9 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
onChange={(e) => setEditStatus(e.target.value)}
className="px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
>
<option value="backlog">Backlog</option>
<option value="in_progress">In Progress</option>
<option value="blocked">Blocked</option>
<option value="done">Done</option>
{(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map(status => (
<option key={status} value={status}>{formatStatusLabel(status)}</option>
))}
</select>
<button
onClick={handleSave}
@@ -157,8 +158,8 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
<Flag size={14} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" />
)}
<span className="text-gray-200">{task.title}</span>
<span className={`ml-2 text-xs ${STATUS_COLORS[task.status]}`}>
{STATUS_LABELS[task.status]}
<span className={`ml-2 text-xs ${getStatusColor(task.status)}`}>
{formatStatusLabel(task.status)}
</span>
</div>
@@ -211,6 +212,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
onUpdate={onUpdate}
onDelete={handleDelete}
onEdit={() => setIsEditing(true)}
projectStatuses={projectStatuses}
/>
</div>
</>
@@ -224,6 +226,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
onSubmit={handleAddSubtask}
onCancel={() => setShowAddSubtask(false)}
submitLabel="Add Subtask"
projectStatuses={projectStatuses}
/>
</div>
)}
@@ -238,6 +241,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
projectId={projectId}
onUpdate={onUpdate}
level={level + 1}
projectStatuses={projectStatuses}
/>
))}
</div>
@@ -246,7 +250,8 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
)
}
function TreeView({ projectId }) {
function TreeView({ projectId, project }) {
const projectStatuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -275,7 +280,7 @@ function TreeView({ projectId }) {
parent_task_id: null,
title: taskData.title,
description: taskData.description,
status: 'backlog',
status: taskData.status,
tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color
@@ -314,6 +319,7 @@ function TreeView({ projectId }) {
onSubmit={handleAddRootTask}
onCancel={() => setShowAddRoot(false)}
submitLabel="Add Task"
projectStatuses={projectStatuses}
/>
</div>
)}
@@ -331,6 +337,7 @@ function TreeView({ projectId }) {
task={task}
projectId={projectId}
onUpdate={loadTasks}
projectStatuses={projectStatuses}
/>
))}
</div>

View File

@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, LayoutList, LayoutGrid } from 'lucide-react'
import { ArrowLeft, LayoutList, LayoutGrid, Settings } from 'lucide-react'
import { getProject } from '../utils/api'
import TreeView from '../components/TreeView'
import KanbanView from '../components/KanbanView'
import ProjectSettings from '../components/ProjectSettings'
function ProjectView() {
const { projectId } = useParams()
@@ -12,6 +13,7 @@ function ProjectView() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [view, setView] = useState('tree') // 'tree' or 'kanban'
const [showSettings, setShowSettings] = useState(false)
useEffect(() => {
loadProject()
@@ -65,6 +67,7 @@ function ProjectView() {
)}
</div>
<div className="flex gap-3 items-center">
<div className="flex gap-2 bg-cyber-darkest rounded-lg p-1 border border-cyber-orange/30">
<button
onClick={() => setView('tree')}
@@ -89,13 +92,30 @@ function ProjectView() {
Kanban
</button>
</div>
<button
onClick={() => setShowSettings(true)}
className="p-2 text-gray-400 hover:text-cyber-orange transition-colors border border-cyber-orange/30 rounded-lg hover:border-cyber-orange/60"
title="Project Settings"
>
<Settings size={20} />
</button>
</div>
</div>
</div>
{view === 'tree' ? (
<TreeView projectId={projectId} />
<TreeView projectId={projectId} project={project} />
) : (
<KanbanView projectId={projectId} />
<KanbanView projectId={projectId} project={project} />
)}
{showSettings && (
<ProjectSettings
project={project}
onClose={() => setShowSettings(false)}
onUpdate={loadProject}
/>
)}
</div>
)