Compare commits
21 Commits
be9c4adeb6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 01e594b941 | |||
|
|
3fc90063b4 | ||
|
|
bd0ac0d95b | ||
|
|
1a6c8cf98c | ||
|
|
8d5ad6a809 | ||
|
|
d692de8f5d | ||
|
|
41b5b1bc1c | ||
|
|
cd8cb658e7 | ||
|
|
fa25cc593a | ||
|
|
6302ce4036 | ||
|
|
66b019c60b | ||
|
|
ae5656babd | ||
|
|
8000a464c9 | ||
|
|
342f8e8d76 | ||
|
|
718e5acbe2 | ||
|
|
c9555737d8 | ||
|
|
3f309163b6 | ||
|
|
444f2744b3 | ||
|
|
b395ee8103 | ||
|
|
70b64d276f | ||
|
|
fc43241833 |
160
ARCHIVE_FEATURE_README.md
Normal file
160
ARCHIVE_FEATURE_README.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# Project Archive Feature
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Break It Down (BIT) application now supports archiving projects! This helps you organize your workspace by hiding completed or inactive projects from the main view while keeping them accessible.
|
||||||
|
|
||||||
|
## Features Added
|
||||||
|
|
||||||
|
### 1. **Archive Status**
|
||||||
|
- Projects can be marked as archived or active
|
||||||
|
- Archived projects are kept in the database but hidden from the default view
|
||||||
|
- All tasks and data are preserved when archiving
|
||||||
|
|
||||||
|
### 2. **Tab Navigation**
|
||||||
|
The main Projects page now has three tabs:
|
||||||
|
- **Active** (default) - Shows only non-archived projects
|
||||||
|
- **Archived** - Shows only archived projects
|
||||||
|
- **All** - Shows all projects regardless of archive status
|
||||||
|
|
||||||
|
### 3. **Quick Actions**
|
||||||
|
Each project card now has two action buttons:
|
||||||
|
- **Archive/Unarchive** (📦/↩️) - Toggle archive status
|
||||||
|
- **Delete** (🗑️) - Permanently delete the project
|
||||||
|
|
||||||
|
### 4. **Visual Indicators**
|
||||||
|
- Archived projects appear with reduced opacity and gray border
|
||||||
|
- "(archived)" label appears next to archived project names
|
||||||
|
- Context-aware empty state messages
|
||||||
|
|
||||||
|
## Database Changes
|
||||||
|
|
||||||
|
A new column has been added to the `projects` table:
|
||||||
|
- `is_archived` (BOOLEAN) - Defaults to `False` for new projects
|
||||||
|
|
||||||
|
## Installation & Migration
|
||||||
|
|
||||||
|
### For New Installations
|
||||||
|
No action needed! The database will be created with the correct schema automatically.
|
||||||
|
|
||||||
|
### For Existing Databases
|
||||||
|
|
||||||
|
If you have an existing Break It Down database, run the migration script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python3 migrate_add_is_archived.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This will add the `is_archived` column to your existing projects table and set all existing projects to `is_archived=False`.
|
||||||
|
|
||||||
|
### Restart the Backend
|
||||||
|
|
||||||
|
After migration, restart the backend server to load the updated models:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop the current backend process
|
||||||
|
pkill -f "uvicorn.*backend.main:app"
|
||||||
|
|
||||||
|
# Start the backend again
|
||||||
|
cd /path/to/break-it-down
|
||||||
|
uvicorn backend.main:app --host 0.0.0.0 --port 8001
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if using Docker:
|
||||||
|
```bash
|
||||||
|
docker-compose restart backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Changes
|
||||||
|
|
||||||
|
### Updated Endpoint: GET /api/projects
|
||||||
|
|
||||||
|
New optional query parameter:
|
||||||
|
- `archived` (boolean, optional) - Filter projects by archive status
|
||||||
|
- `archived=false` - Returns only active projects
|
||||||
|
- `archived=true` - Returns only archived projects
|
||||||
|
- No parameter - Returns all projects
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
```bash
|
||||||
|
# Get active projects
|
||||||
|
GET /api/projects?archived=false
|
||||||
|
|
||||||
|
# Get archived projects
|
||||||
|
GET /api/projects?archived=true
|
||||||
|
|
||||||
|
# Get all projects
|
||||||
|
GET /api/projects
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updated Endpoint: PUT /api/projects/{id}
|
||||||
|
|
||||||
|
New field in request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Project Name", // optional
|
||||||
|
"description": "Description", // optional
|
||||||
|
"statuses": [...], // optional
|
||||||
|
"is_archived": true // optional - new!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New API Helper Functions
|
||||||
|
|
||||||
|
The frontend API client now includes:
|
||||||
|
- `archiveProject(id)` - Archive a project
|
||||||
|
- `unarchiveProject(id)` - Unarchive a project
|
||||||
|
|
||||||
|
## File Changes Summary
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `backend/app/models.py` - Added `is_archived` Boolean column to Project model
|
||||||
|
- `backend/app/schemas.py` - Updated ProjectUpdate and Project schemas
|
||||||
|
- `backend/app/main.py` - Added `archived` query parameter to list_projects endpoint
|
||||||
|
- `backend/app/crud.py` - Updated get_projects to support archive filtering
|
||||||
|
- `backend/migrate_add_is_archived.py` - Migration script (new file)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `frontend/src/pages/ProjectList.jsx` - Added tabs, archive buttons, and filtering logic
|
||||||
|
- `frontend/src/utils/api.js` - Added archive/unarchive functions and updated getProjects
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Archive a Project
|
||||||
|
1. Go to the Projects page
|
||||||
|
2. Click the archive icon (📦) on any project card
|
||||||
|
3. The project disappears from the Active view
|
||||||
|
4. Switch to the "Archived" tab to see it
|
||||||
|
|
||||||
|
### Unarchive a Project
|
||||||
|
1. Switch to the "Archived" tab
|
||||||
|
2. Click the unarchive icon (↩️) on the project card
|
||||||
|
3. The project returns to the Active view
|
||||||
|
|
||||||
|
### View All Projects
|
||||||
|
Click the "All" tab to see both active and archived projects together.
|
||||||
|
|
||||||
|
## Status vs Archive
|
||||||
|
|
||||||
|
**Important distinction:**
|
||||||
|
- **Task Status** (backlog, in_progress, on_hold, done) - Applied to individual tasks within a project
|
||||||
|
- **Project Archive** - Applied to entire projects to organize your workspace
|
||||||
|
|
||||||
|
The existing task status "on_hold" is still useful for pausing work on specific tasks, while archiving is for entire projects you want to hide from your main view.
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
All changes are backward compatible:
|
||||||
|
- Existing projects will default to `is_archived=false` (active)
|
||||||
|
- Old API calls without the `archived` parameter still work (returns all projects)
|
||||||
|
- The frontend gracefully handles projects with or without the archive field
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential additions:
|
||||||
|
- Archive date tracking
|
||||||
|
- Bulk archive operations
|
||||||
|
- Archive projects from the ProjectSettings modal
|
||||||
|
- Auto-archive projects after X days of inactivity
|
||||||
|
- Project status badges (active, archived, on-hold, completed)
|
||||||
246
CHANGELOG.md
Normal file
246
CHANGELOG.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to Break It Down (BIT) will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.1.6] - 2025-11-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Dynamic Status Management System**
|
||||||
|
- Per-project customizable status workflows (replacing hardcoded statuses)
|
||||||
|
- New default statuses: `backlog`, `in_progress`, `on_hold`, `done` (removed `blocked`)
|
||||||
|
- Project Settings modal with comprehensive status management:
|
||||||
|
- Drag-and-drop status reordering
|
||||||
|
- Add new custom statuses
|
||||||
|
- Rename existing statuses (inline editing)
|
||||||
|
- Delete unused statuses with validation
|
||||||
|
- Task count display per status
|
||||||
|
- Warning dialogs when attempting to delete statuses with tasks
|
||||||
|
- Settings button (⚙️) in project header to access configuration
|
||||||
|
- Status validation in backend CRUD operations
|
||||||
|
- Database migration for `projects.statuses` JSON column
|
||||||
|
|
||||||
|
- **Enhanced Task Creation**
|
||||||
|
- Status selector dropdown in TaskForm component
|
||||||
|
- Select status when creating tasks (no longer defaults to "backlog" only)
|
||||||
|
- Works across Tree View and Kanban View
|
||||||
|
- Defaults to current column status in Kanban View
|
||||||
|
- Defaults to "backlog" in Tree View
|
||||||
|
|
||||||
|
- **Subtask Creation in Kanban View**
|
||||||
|
- "+" button on task cards to add subtasks directly in Kanban
|
||||||
|
- Button appears on hover next to task menu
|
||||||
|
- Opens TaskForm with status defaulted to current column
|
||||||
|
- Automatically expands parent card after creating subtask
|
||||||
|
- Full parity with Tree View subtask functionality
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Removed hardcoded status enums from backend (`TaskStatus` enum removed)
|
||||||
|
- Task status field changed from Enum to String in database
|
||||||
|
- All status references now dynamic (backend, frontend, Kanban UI)
|
||||||
|
- Status formatting helpers added for consistent display
|
||||||
|
- Status colors now pattern-matched based on status names
|
||||||
|
- Existing tasks migrated from uppercase enum format to lowercase
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
- `models.py`: Added `DEFAULT_STATUSES` constant, `statuses` JSON column to Project, changed Task.status to String
|
||||||
|
- `schemas.py`: Changed status from TaskStatus enum to str, added statuses to Project schemas
|
||||||
|
- `crud.py`: Added status validation in create_task, update_task, get_tasks_by_status
|
||||||
|
- `main.py`: Added status validation to endpoints and JSON import
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
- `ProjectSettings.jsx`: New component for managing project status workflows
|
||||||
|
- `TaskForm.jsx`: Added status selector with dynamic status list
|
||||||
|
- `KanbanView.jsx`: Dynamic status columns, subtask creation, removed hardcoded STATUSES
|
||||||
|
- `TaskMenu.jsx`: Dynamic status dropdown using projectStatuses prop
|
||||||
|
- `TreeView.jsx`: Dynamic status helpers, passes statuses to forms
|
||||||
|
- `ProjectView.jsx`: Integrated ProjectSettings modal with gear icon button
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Database schema migration for projects.statuses column
|
||||||
|
- Task status migration from uppercase (BACKLOG, IN_PROGRESS, DONE) to lowercase format
|
||||||
|
- 51 existing tasks successfully migrated to new status format
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- New database column: `projects.statuses` (JSON, default: ["backlog", "in_progress", "on_hold", "done"])
|
||||||
|
- Status validation at multiple layers (CRUD, API endpoints, frontend)
|
||||||
|
- Helper functions for status formatting and color coding
|
||||||
|
- Prop drilling of projectStatuses through component hierarchy
|
||||||
|
|
||||||
|
## [0.1.5] - 2025-11-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Nested Kanban View** - Major feature implementation
|
||||||
|
- Parent tasks now appear in each column where they have subtasks
|
||||||
|
- Parent cards show "X of Y subtasks in this column" indicator
|
||||||
|
- Parent cards are expandable/collapsible to show children in that column
|
||||||
|
- Parent cards have distinct visual styling (thicker orange border, bold text)
|
||||||
|
- Only leaf tasks (tasks with no children) are draggable
|
||||||
|
- Parent cards automatically appear in multiple columns as children move
|
||||||
|
- Helper functions for nested Kanban logic:
|
||||||
|
- `getDescendantsInStatus()` - Get all descendant tasks in a specific status
|
||||||
|
- `hasDescendantsInStatus()` - Check if parent has any descendants in a status
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Kanban board now labeled "Kanban Board (Nested View)"
|
||||||
|
- Parent task cards cannot be dragged (only leaf tasks)
|
||||||
|
- Column task counts now include parent cards
|
||||||
|
- Improved visual hierarchy with parent/child distinction
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Better visualization of task distribution across statuses
|
||||||
|
- Easier to see project structure while maintaining status-based organization
|
||||||
|
- Parent tasks provide context for subtasks in each column
|
||||||
|
|
||||||
|
## [0.1.4] - 2025-01-XX
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Strikethrough styling for time estimates when tasks are marked as "done"
|
||||||
|
- Auto-complete parent tasks when all child tasks are marked as "done"
|
||||||
|
- Works recursively up the task hierarchy
|
||||||
|
- Parents automatically transition to "done" status when all children complete
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Time estimates on completed tasks now display with strikethrough decoration
|
||||||
|
- Parent task status automatically updates based on children completion state
|
||||||
|
|
||||||
|
## [0.1.3] - 2025-01-XX
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Enhanced task creation forms with metadata fields
|
||||||
|
- Title field (required)
|
||||||
|
- Tags field (comma-separated input)
|
||||||
|
- Time estimate fields (hours and minutes)
|
||||||
|
- Flag color selector with 8 color options
|
||||||
|
- TaskForm component for consistent task creation across views
|
||||||
|
- Status change dropdown in TaskMenu (no longer requires Kanban view)
|
||||||
|
- Leaf-based time calculation system
|
||||||
|
- Parent tasks show sum of descendant leaf task estimates
|
||||||
|
- Prevents double-counting when both parents and children have estimates
|
||||||
|
- Excludes "done" tasks from time calculations
|
||||||
|
- Time format changed from decimal hours to hours + minutes (e.g., "1h 30m" instead of "1.5h")
|
||||||
|
- CHANGELOG.md and README.md documentation
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Task creation now includes all metadata fields upfront
|
||||||
|
- Time estimates display remaining work (excludes completed tasks)
|
||||||
|
- Time input uses separate hours/minutes fields instead of single minutes field
|
||||||
|
- Parent task estimates calculated from leaf descendants only
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Time calculation now accurately represents remaining work
|
||||||
|
- Time format more human-readable with hours and minutes
|
||||||
|
|
||||||
|
## [0.1.2] - 2025-01-XX
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Metadata fields for tasks:
|
||||||
|
- `estimated_minutes` (Integer) - Time estimate stored in minutes
|
||||||
|
- `tags` (JSON Array) - Categorization tags
|
||||||
|
- `flag_color` (String) - Priority flag with 7 color options
|
||||||
|
- TaskMenu component with three-dot dropdown
|
||||||
|
- Edit time estimates
|
||||||
|
- Edit tags (comma-separated)
|
||||||
|
- Set flag colors
|
||||||
|
- Edit task title
|
||||||
|
- Delete tasks
|
||||||
|
- SearchBar component in header
|
||||||
|
- Real-time search with 300ms debounce
|
||||||
|
- Optional project filtering
|
||||||
|
- Click results to navigate to project
|
||||||
|
- Displays metadata in results
|
||||||
|
- Time and tag display in TreeView and KanbanView
|
||||||
|
- Flag color indicators on tasks
|
||||||
|
- Backend search endpoint `/api/search` with project filtering
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- TreeView and KanbanView now display task metadata
|
||||||
|
- Enhanced visual design with metadata badges
|
||||||
|
|
||||||
|
## [0.1.1] - 2025-01-XX
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Tree view indentation now scales properly with nesting depth
|
||||||
|
- Changed from fixed `ml-6` to calculated `marginLeft: ${level * 1.5}rem`
|
||||||
|
- Each nesting level adds 1.5rem (24px) of indentation
|
||||||
|
- Kanban view subtask handling
|
||||||
|
- All tasks (including subtasks) now appear as individual draggable cards
|
||||||
|
- Subtasks show parent context: "↳ subtask of: [parent name]"
|
||||||
|
- Removed nested subtask list display
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Improved visual hierarchy in tree view
|
||||||
|
- Better subtask representation in Kanban board
|
||||||
|
|
||||||
|
## [0.1.0] - 2025-01-XX
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial MVP release
|
||||||
|
- Core Features:
|
||||||
|
- Arbitrary-depth nested task hierarchies
|
||||||
|
- Two view modes: Tree View and Kanban Board
|
||||||
|
- Self-hosted architecture with Docker deployment
|
||||||
|
- JSON import for LLM-generated task trees
|
||||||
|
- Technology Stack:
|
||||||
|
- Backend: Python FastAPI with SQLAlchemy ORM
|
||||||
|
- Database: SQLite with self-referencing Task model
|
||||||
|
- Frontend: React + Tailwind CSS
|
||||||
|
- Deployment: Docker with nginx reverse proxy
|
||||||
|
- Project Management:
|
||||||
|
- Create/read/update/delete projects
|
||||||
|
- Project-specific task trees
|
||||||
|
- Task Management:
|
||||||
|
- Create tasks with title, description, status
|
||||||
|
- Four status types: Backlog, In Progress, Blocked, Done
|
||||||
|
- Hierarchical task nesting (task → subtask → sub-subtask → ...)
|
||||||
|
- Add subtasks to any task
|
||||||
|
- Delete tasks (cascading to all subtasks)
|
||||||
|
- Tree View:
|
||||||
|
- Collapsible hierarchical display
|
||||||
|
- Expand/collapse subtasks
|
||||||
|
- Visual nesting indentation
|
||||||
|
- Inline editing
|
||||||
|
- Status display
|
||||||
|
- Kanban Board:
|
||||||
|
- Four columns: Backlog, In Progress, Blocked, Done
|
||||||
|
- Drag-and-drop to change status
|
||||||
|
- All tasks shown as cards (including subtasks)
|
||||||
|
- JSON Import:
|
||||||
|
- Bulk import task trees from JSON files
|
||||||
|
- Supports arbitrary nesting depth
|
||||||
|
- Example import file included
|
||||||
|
- UI/UX:
|
||||||
|
- Dark cyberpunk theme
|
||||||
|
- Orange (#ff6b35) accent color
|
||||||
|
- Responsive design
|
||||||
|
- Real-time updates
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- Backend API endpoints:
|
||||||
|
- `/api/projects` - Project CRUD
|
||||||
|
- `/api/tasks` - Task CRUD
|
||||||
|
- `/api/projects/{id}/tree` - Hierarchical task tree
|
||||||
|
- `/api/projects/{id}/tasks` - Flat task list
|
||||||
|
- `/api/projects/{id}/import` - JSON import
|
||||||
|
- Database Schema:
|
||||||
|
- `projects` table with id, name, description
|
||||||
|
- `tasks` table with self-referencing `parent_task_id`
|
||||||
|
- Frontend Routing:
|
||||||
|
- `/` - Project list
|
||||||
|
- `/project/:id` - Project view with Tree/Kanban toggle
|
||||||
|
- Docker Setup:
|
||||||
|
- Multi-stage builds for optimization
|
||||||
|
- Nginx reverse proxy configuration
|
||||||
|
- Named volumes for database persistence
|
||||||
|
- Development and production configurations
|
||||||
|
|
||||||
|
## Project Information
|
||||||
|
|
||||||
|
**Break It Down (BIT)** - Task Decomposition Engine
|
||||||
|
A self-hosted web application for managing deeply nested todo trees with advanced time tracking and project planning capabilities.
|
||||||
|
|
||||||
|
**Repository**: https://github.com/serversdwn/break-it-down
|
||||||
|
**License**: MIT
|
||||||
|
**Author**: serversdwn
|
||||||
14
backend/.env.example
Normal file
14
backend/.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL=sqlite:///./bit.db
|
||||||
|
|
||||||
|
# API Configuration
|
||||||
|
API_TITLE=Break It Down (BIT) - Nested Todo Tree API
|
||||||
|
API_DESCRIPTION=API for managing deeply nested todo trees
|
||||||
|
API_VERSION=1.0.0
|
||||||
|
|
||||||
|
# CORS Configuration (comma-separated list of allowed origins)
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
@@ -5,7 +5,12 @@ from . import models, schemas
|
|||||||
|
|
||||||
# Project CRUD
|
# Project CRUD
|
||||||
def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project:
|
def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project:
|
||||||
db_project = models.Project(**project.model_dump())
|
project_data = project.model_dump()
|
||||||
|
# Ensure statuses has a default value if not provided
|
||||||
|
if project_data.get("statuses") is None:
|
||||||
|
project_data["statuses"] = models.DEFAULT_STATUSES
|
||||||
|
|
||||||
|
db_project = models.Project(**project_data)
|
||||||
db.add(db_project)
|
db.add(db_project)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_project)
|
db.refresh(db_project)
|
||||||
@@ -16,8 +21,14 @@ 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) -> List[models.Project]:
|
def get_projects(db: Session, skip: int = 0, limit: int = 100, archived: Optional[bool] = None) -> List[models.Project]:
|
||||||
return db.query(models.Project).offset(skip).limit(limit).all()
|
query = db.query(models.Project)
|
||||||
|
|
||||||
|
# Filter by archive status if specified
|
||||||
|
if archived is not None:
|
||||||
|
query = query.filter(models.Project.is_archived == archived)
|
||||||
|
|
||||||
|
return query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
def update_project(
|
def update_project(
|
||||||
@@ -47,6 +58,11 @@ def delete_project(db: Session, project_id: int) -> bool:
|
|||||||
|
|
||||||
# Task CRUD
|
# Task CRUD
|
||||||
def create_task(db: Session, task: schemas.TaskCreate) -> models.Task:
|
def create_task(db: Session, task: schemas.TaskCreate) -> models.Task:
|
||||||
|
# Validate status against project's statuses
|
||||||
|
project = get_project(db, task.project_id)
|
||||||
|
if project and task.status not in project.statuses:
|
||||||
|
raise ValueError(f"Invalid status '{task.status}'. Must be one of: {', '.join(project.statuses)}")
|
||||||
|
|
||||||
# Get max sort_order for siblings
|
# Get max sort_order for siblings
|
||||||
if task.parent_task_id:
|
if task.parent_task_id:
|
||||||
max_order = db.query(models.Task).filter(
|
max_order = db.query(models.Task).filter(
|
||||||
@@ -92,6 +108,32 @@ def get_task_with_subtasks(db: Session, task_id: int) -> Optional[models.Task]:
|
|||||||
).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):
|
||||||
|
"""Check if all children of a parent are done, and mark parent as done if so"""
|
||||||
|
# Get all children of this parent
|
||||||
|
children = db.query(models.Task).filter(
|
||||||
|
models.Task.parent_task_id == parent_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# If no children, nothing to do
|
||||||
|
if not children:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if all children are done
|
||||||
|
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 != "done":
|
||||||
|
parent.status = "done"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Recursively check grandparent
|
||||||
|
if 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]:
|
||||||
@@ -100,11 +142,27 @@ def update_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
|
||||||
|
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():
|
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 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
|
return db_task
|
||||||
|
|
||||||
|
|
||||||
@@ -117,8 +175,13 @@ def delete_task(db: Session, task_id: int) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def get_tasks_by_status(db: Session, project_id: int, status: models.TaskStatus) -> List[models.Task]:
|
def get_tasks_by_status(db: Session, project_id: int, status: str) -> List[models.Task]:
|
||||||
"""Get all tasks for a project with a specific status"""
|
"""Get all tasks for a project with a specific status"""
|
||||||
|
# Validate status against project's statuses
|
||||||
|
project = get_project(db, project_id)
|
||||||
|
if project and status not in project.statuses:
|
||||||
|
raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(project.statuses)}")
|
||||||
|
|
||||||
return db.query(models.Task).filter(
|
return db.query(models.Task).filter(
|
||||||
models.Task.project_id == project_id,
|
models.Task.project_id == project_id,
|
||||||
models.Task.status == status
|
models.Task.status == status
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from .settings import settings
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./tesseract.db"
|
SQLALCHEMY_DATABASE_URL = settings.database_url
|
||||||
|
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||||
|
|||||||
@@ -6,20 +6,21 @@ 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
|
||||||
|
|
||||||
# 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="Tesseract - Nested Todo Tree API",
|
title=settings.api_title,
|
||||||
description="API for managing deeply nested todo trees",
|
description=settings.api_description,
|
||||||
version="1.0.0"
|
version=settings.api_version
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS middleware for frontend
|
# CORS middleware for frontend
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["http://localhost:5173", "http://localhost:3000"], # Vite default port
|
allow_origins=settings.cors_origins_list,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@@ -29,9 +30,14 @@ app.add_middleware(
|
|||||||
# ========== 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(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
def list_projects(
|
||||||
"""List all projects"""
|
skip: int = 0,
|
||||||
return crud.get_projects(db, skip=skip, limit=limit)
|
limit: int = 100,
|
||||||
|
archived: Optional[bool] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""List all projects with optional archive filter"""
|
||||||
|
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)
|
||||||
@@ -97,13 +103,16 @@ def get_project_task_tree(project_id: int, db: Session = Depends(get_db)):
|
|||||||
@app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task])
|
@app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task])
|
||||||
def get_tasks_by_status(
|
def get_tasks_by_status(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
status: models.TaskStatus,
|
status: str,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get all tasks for a project filtered by status (for Kanban view)"""
|
"""Get all tasks for a project filtered by status (for Kanban view)"""
|
||||||
if not crud.get_project(db, project_id):
|
if not crud.get_project(db, project_id):
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
return crud.get_tasks_by_status(db, project_id, status)
|
try:
|
||||||
|
return crud.get_tasks_by_status(db, project_id, status)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/tasks", response_model=schemas.Task, status_code=201)
|
@app.post("/api/tasks", response_model=schemas.Task, status_code=201)
|
||||||
@@ -115,7 +124,10 @@ def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
|
|||||||
if task.parent_task_id and not crud.get_task(db, task.parent_task_id):
|
if task.parent_task_id and not crud.get_task(db, task.parent_task_id):
|
||||||
raise HTTPException(status_code=404, detail="Parent task not found")
|
raise HTTPException(status_code=404, detail="Parent task not found")
|
||||||
|
|
||||||
return crud.create_task(db, task)
|
try:
|
||||||
|
return crud.create_task(db, task)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/tasks/{task_id}", response_model=schemas.Task)
|
@app.get("/api/tasks/{task_id}", response_model=schemas.Task)
|
||||||
@@ -130,10 +142,13 @@ def get_task(task_id: int, db: Session = Depends(get_db)):
|
|||||||
@app.put("/api/tasks/{task_id}", response_model=schemas.Task)
|
@app.put("/api/tasks/{task_id}", response_model=schemas.Task)
|
||||||
def update_task(task_id: int, task: schemas.TaskUpdate, db: Session = Depends(get_db)):
|
def update_task(task_id: int, task: schemas.TaskUpdate, db: Session = Depends(get_db)):
|
||||||
"""Update a task"""
|
"""Update a task"""
|
||||||
db_task = crud.update_task(db, task_id, task)
|
try:
|
||||||
if not db_task:
|
db_task = crud.update_task(db, task_id, task)
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
if not db_task:
|
||||||
return 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)
|
@app.delete("/api/tasks/{task_id}", status_code=204)
|
||||||
@@ -187,6 +202,27 @@ def search_tasks(
|
|||||||
|
|
||||||
# ========== JSON IMPORT ENDPOINT ==========
|
# ========== JSON IMPORT ENDPOINT ==========
|
||||||
|
|
||||||
|
def _validate_task_statuses_recursive(
|
||||||
|
tasks: List[schemas.ImportSubtask],
|
||||||
|
valid_statuses: List[str],
|
||||||
|
path: str = ""
|
||||||
|
) -> None:
|
||||||
|
"""Recursively validate all task statuses against the project's valid statuses"""
|
||||||
|
for idx, task_data in enumerate(tasks):
|
||||||
|
task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]"
|
||||||
|
if task_data.status not in valid_statuses:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid status '{task_data.status}' at {task_path}. "
|
||||||
|
f"Must be one of: {', '.join(valid_statuses)}"
|
||||||
|
)
|
||||||
|
if task_data.subtasks:
|
||||||
|
_validate_task_statuses_recursive(
|
||||||
|
task_data.subtasks,
|
||||||
|
valid_statuses,
|
||||||
|
f"{task_path}.subtasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _import_tasks_recursive(
|
def _import_tasks_recursive(
|
||||||
db: Session,
|
db: Session,
|
||||||
project_id: int,
|
project_id: int,
|
||||||
@@ -227,7 +263,8 @@ def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_
|
|||||||
{
|
{
|
||||||
"project": {
|
"project": {
|
||||||
"name": "Project Name",
|
"name": "Project Name",
|
||||||
"description": "Optional description"
|
"description": "Optional description",
|
||||||
|
"statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional
|
||||||
},
|
},
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
@@ -245,15 +282,26 @@ def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
# Create the project
|
# Create the project with optional statuses
|
||||||
project = crud.create_project(
|
project = crud.create_project(
|
||||||
db,
|
db,
|
||||||
schemas.ProjectCreate(
|
schemas.ProjectCreate(
|
||||||
name=import_data.project.name,
|
name=import_data.project.name,
|
||||||
description=import_data.project.description
|
description=import_data.project.description,
|
||||||
|
statuses=import_data.project.statuses
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate all task statuses before importing
|
||||||
|
if import_data.tasks:
|
||||||
|
try:
|
||||||
|
_validate_task_statuses_recursive(import_data.tasks, project.statuses)
|
||||||
|
except ValueError as e:
|
||||||
|
# Rollback the project creation if validation fails
|
||||||
|
db.delete(project)
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
# Recursively import tasks
|
# Recursively import tasks
|
||||||
tasks_created = _import_tasks_recursive(
|
tasks_created = _import_tasks_recursive(
|
||||||
db, project.id, import_data.tasks
|
db, project.id, import_data.tasks
|
||||||
@@ -271,6 +319,6 @@ def root():
|
|||||||
"""API health check"""
|
"""API health check"""
|
||||||
return {
|
return {
|
||||||
"status": "online",
|
"status": "online",
|
||||||
"message": "Tesseract API - Nested Todo Tree Manager",
|
"message": "Break It Down (BIT) API - Nested Todo Tree Manager",
|
||||||
"docs": "/docs"
|
"docs": "/docs"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, Boolean
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import enum
|
|
||||||
from .database import Base
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
class TaskStatus(str, enum.Enum):
|
# Default statuses for new projects
|
||||||
BACKLOG = "backlog"
|
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
|
||||||
IN_PROGRESS = "in_progress"
|
|
||||||
BLOCKED = "blocked"
|
|
||||||
DONE = "done"
|
|
||||||
|
|
||||||
|
|
||||||
class Project(Base):
|
class Project(Base):
|
||||||
@@ -18,6 +14,8 @@ class Project(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
name = Column(String(255), nullable=False)
|
name = Column(String(255), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
|
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
|
||||||
|
is_archived = Column(Boolean, default=False, nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
@@ -32,7 +30,7 @@ class Task(Base):
|
|||||||
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
|
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
|
||||||
title = Column(String(500), nullable=False)
|
title = Column(String(500), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
status = Column(Enum(TaskStatus), default=TaskStatus.BACKLOG, nullable=False)
|
status = Column(String(50), default="backlog", nullable=False)
|
||||||
sort_order = Column(Integer, default=0)
|
sort_order = Column(Integer, default=0)
|
||||||
estimated_minutes = Column(Integer, nullable=True)
|
estimated_minutes = Column(Integer, nullable=True)
|
||||||
tags = Column(JSON, nullable=True)
|
tags = Column(JSON, nullable=True)
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from .models import TaskStatus
|
from .models import DEFAULT_STATUSES
|
||||||
|
|
||||||
|
|
||||||
# Task Schemas
|
# Task Schemas
|
||||||
class TaskBase(BaseModel):
|
class TaskBase(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
status: TaskStatus = TaskStatus.BACKLOG
|
status: str = "backlog"
|
||||||
parent_task_id: Optional[int] = None
|
parent_task_id: Optional[int] = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
estimated_minutes: Optional[int] = None
|
estimated_minutes: Optional[int] = None
|
||||||
@@ -23,7 +23,7 @@ class TaskCreate(TaskBase):
|
|||||||
class TaskUpdate(BaseModel):
|
class TaskUpdate(BaseModel):
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
status: Optional[TaskStatus] = None
|
status: Optional[str] = None
|
||||||
parent_task_id: Optional[int] = None
|
parent_task_id: Optional[int] = None
|
||||||
sort_order: Optional[int] = None
|
sort_order: Optional[int] = None
|
||||||
estimated_minutes: Optional[int] = None
|
estimated_minutes: Optional[int] = None
|
||||||
@@ -53,16 +53,20 @@ class ProjectBase(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ProjectCreate(ProjectBase):
|
class ProjectCreate(ProjectBase):
|
||||||
pass
|
statuses: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(BaseModel):
|
class ProjectUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
statuses: Optional[List[str]] = None
|
||||||
|
is_archived: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class Project(ProjectBase):
|
class Project(ProjectBase):
|
||||||
id: int
|
id: int
|
||||||
|
statuses: List[str]
|
||||||
|
is_archived: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -79,7 +83,7 @@ class ProjectWithTasks(Project):
|
|||||||
class ImportSubtask(BaseModel):
|
class ImportSubtask(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
status: TaskStatus = TaskStatus.BACKLOG
|
status: str = "backlog"
|
||||||
estimated_minutes: Optional[int] = None
|
estimated_minutes: Optional[int] = None
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
flag_color: Optional[str] = None
|
flag_color: Optional[str] = None
|
||||||
@@ -89,6 +93,7 @@ class ImportSubtask(BaseModel):
|
|||||||
class ImportProject(BaseModel):
|
class ImportProject(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
statuses: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class ImportData(BaseModel):
|
class ImportData(BaseModel):
|
||||||
|
|||||||
37
backend/app/settings.py
Normal file
37
backend/app/settings.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings loaded from environment variables"""
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
database_url: str = "sqlite:///./bit.db"
|
||||||
|
|
||||||
|
# API Configuration
|
||||||
|
api_title: str = "Break It Down (BIT) - Nested Todo Tree API"
|
||||||
|
api_description: str = "API for managing deeply nested todo trees"
|
||||||
|
api_version: str = "1.0.0"
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
cors_origins: str = "http://localhost:5173,http://localhost:3000"
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 8000
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
extra="ignore"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origins_list(self) -> List[str]:
|
||||||
|
"""Parse comma-separated CORS origins into a list"""
|
||||||
|
return [origin.strip() for origin in self.cors_origins.split(",")]
|
||||||
|
|
||||||
|
|
||||||
|
# Global settings instance
|
||||||
|
settings = Settings()
|
||||||
37
backend/migrate_add_is_archived.py
Normal file
37
backend/migrate_add_is_archived.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Migration script to add is_archived column to existing projects.
|
||||||
|
Run this once if you have an existing database.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Get the database path
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Connect to the database
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if the column already exists
|
||||||
|
cursor.execute("PRAGMA table_info(projects)")
|
||||||
|
columns = [column[1] for column in cursor.fetchall()]
|
||||||
|
|
||||||
|
if 'is_archived' in columns:
|
||||||
|
print("Column 'is_archived' already exists. Migration not needed.")
|
||||||
|
else:
|
||||||
|
# Add the is_archived column
|
||||||
|
cursor.execute("ALTER TABLE projects ADD COLUMN is_archived BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
conn.commit()
|
||||||
|
print("Successfully added 'is_archived' column to projects table.")
|
||||||
|
print("All existing projects have been set to is_archived=False (0).")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during migration: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
62
backend/migrate_add_statuses.py
Normal file
62
backend/migrate_add_statuses.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
Migration script to add statuses column to projects table
|
||||||
|
Run this script once to update existing database
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Default statuses for existing projects
|
||||||
|
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
# Connect to the database
|
||||||
|
conn = sqlite3.connect('bit.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)
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: tesseract-backend
|
container_name: bit-backend
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8002:8002"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/app:/app/app
|
- ./backend/app:/app/app
|
||||||
- tesseract-db:/app
|
- bit-db:/app
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -19,12 +19,14 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: tesseract-frontend
|
container_name: bit-frontend
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3002:80"
|
||||||
|
env_file:
|
||||||
|
- ./frontend/.env
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
tesseract-db:
|
bit-db:
|
||||||
|
|||||||
18
frontend/.env.example
Normal file
18
frontend/.env.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# API Configuration
|
||||||
|
# Base URL for API requests (relative path used in production)
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
|
|
||||||
|
# Backend API URL (used for development proxy)
|
||||||
|
VITE_API_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# Development Configuration
|
||||||
|
# Port for Vite development server
|
||||||
|
VITE_DEV_PORT=5173
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
# Application version displayed in UI
|
||||||
|
VITE_APP_VERSION=0.1.5
|
||||||
|
|
||||||
|
# UI/UX Configuration
|
||||||
|
# Search input debounce delay in milliseconds
|
||||||
|
VITE_SEARCH_DEBOUNCE_MS=300
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tesseract - Task Decomposition Engine</title>
|
<title>Break It Down - Task Decomposition Engine</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "tesseract-frontend",
|
"name": "bit-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tesseract-frontend",
|
"name": "bit-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^0.303.0",
|
"lucide-react": "^0.303.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "tesseract-frontend",
|
"name": "bit-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Routes, Route } from 'react-router-dom'
|
import { Routes, Route } from 'react-router-dom'
|
||||||
import ProjectList from './pages/ProjectList'
|
import ProjectList from './pages/ProjectList'
|
||||||
import ProjectView from './pages/ProjectView'
|
import ProjectView from './pages/ProjectView'
|
||||||
|
import SearchBar from './components/SearchBar'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -10,11 +11,12 @@ function App() {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-cyber-orange">
|
<h1 className="text-2xl font-bold text-cyber-orange">
|
||||||
TESSERACT
|
Break It Down
|
||||||
<span className="ml-3 text-sm text-gray-500">Task Decomposition Engine</span>
|
<span className="ml-3 text-sm text-gray-500">BIT - Task Decomposition Engine</span>
|
||||||
<span className="ml-2 text-xs text-gray-600">v0.1.3</span>
|
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<SearchBar />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,27 +1,87 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Plus, Edit2, Trash2, Check, X } from 'lucide-react'
|
import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
getProjectTasks,
|
getProjectTasks,
|
||||||
createTask,
|
createTask,
|
||||||
updateTask,
|
updateTask,
|
||||||
deleteTask
|
deleteTask
|
||||||
} from '../utils/api'
|
} from '../utils/api'
|
||||||
|
import { formatTimeWithTotal } from '../utils/format'
|
||||||
|
import TaskMenu from './TaskMenu'
|
||||||
|
import TaskForm from './TaskForm'
|
||||||
|
|
||||||
const STATUSES = [
|
// Helper to format status label
|
||||||
{ key: 'backlog', label: 'Backlog', color: 'border-gray-600' },
|
const formatStatusLabel = (status) => {
|
||||||
{ key: 'in_progress', label: 'In Progress', color: 'border-blue-500' },
|
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||||
{ key: 'blocked', label: 'Blocked', color: 'border-red-500' },
|
}
|
||||||
{ key: 'done', label: 'Done', color: 'border-green-500' }
|
|
||||||
]
|
|
||||||
|
|
||||||
function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
// 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',
|
||||||
|
orange: 'bg-orange-500',
|
||||||
|
yellow: 'bg-yellow-500',
|
||||||
|
green: 'bg-green-500',
|
||||||
|
blue: 'bg-blue-500',
|
||||||
|
purple: 'bg-purple-500',
|
||||||
|
pink: 'bg-pink-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get all descendant tasks recursively
|
||||||
|
function getAllDescendants(taskId, allTasks) {
|
||||||
|
const children = allTasks.filter(t => t.parent_task_id === taskId)
|
||||||
|
let descendants = [...children]
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
descendants = descendants.concat(getAllDescendants(child.id, allTasks))
|
||||||
|
}
|
||||||
|
|
||||||
|
return descendants
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get all descendant tasks of a parent in a specific status
|
||||||
|
function getDescendantsInStatus(taskId, allTasks, status) {
|
||||||
|
const children = allTasks.filter(t => t.parent_task_id === taskId)
|
||||||
|
let descendants = []
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.status === status) {
|
||||||
|
descendants.push(child)
|
||||||
|
}
|
||||||
|
// Recursively get descendants
|
||||||
|
descendants = descendants.concat(getDescendantsInStatus(child.id, allTasks, status))
|
||||||
|
}
|
||||||
|
|
||||||
|
return descendants
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if a task has any descendants in a status
|
||||||
|
function hasDescendantsInStatus(taskId, allTasks, status) {
|
||||||
|
return getDescendantsInStatus(taskId, allTasks, status).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards, projectStatuses, projectId }) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [editTitle, setEditTitle] = useState(task.title)
|
const [editTitle, setEditTitle] = useState(task.title)
|
||||||
|
const [showAddSubtask, setShowAddSubtask] = useState(false)
|
||||||
|
|
||||||
// Find parent task if this is a subtask
|
// Use global expanded state
|
||||||
const parentTask = task.parent_task_id
|
const isExpanded = expandedCards[task.id] || false
|
||||||
? allTasks.find(t => t.id === task.parent_task_id)
|
const toggleExpanded = () => {
|
||||||
: null
|
setExpandedCards(prev => ({
|
||||||
|
...prev,
|
||||||
|
[task.id]: !prev[task.id]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -43,85 +103,209 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="mb-2">
|
||||||
draggable={!isEditing}
|
<div
|
||||||
onDragStart={(e) => onDragStart(e, task)}
|
draggable={!isEditing}
|
||||||
className="bg-cyber-darkest border border-cyber-orange/30 rounded-lg p-3 mb-2 cursor-move hover:border-cyber-orange/60 transition-all group"
|
onDragStart={(e) => onDragStart(e, task, isParent)}
|
||||||
>
|
className={`${
|
||||||
{isEditing ? (
|
isParent
|
||||||
<div className="flex gap-2">
|
? 'bg-cyber-darker border-2 border-cyber-orange/50'
|
||||||
<input
|
: 'bg-cyber-darkest border border-cyber-orange/30'
|
||||||
type="text"
|
} rounded-lg p-3 cursor-move hover:border-cyber-orange/60 transition-all group`}
|
||||||
value={editTitle}
|
>
|
||||||
onChange={(e) => setEditTitle(e.target.value)}
|
{isEditing ? (
|
||||||
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 className="flex gap-2">
|
||||||
autoFocus
|
<input
|
||||||
/>
|
type="text"
|
||||||
<button
|
value={editTitle}
|
||||||
onClick={handleSave}
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
className="text-green-400 hover:text-green-300"
|
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
|
||||||
<Check size={16} />
|
/>
|
||||||
</button>
|
<button
|
||||||
<button
|
onClick={handleSave}
|
||||||
onClick={() => {
|
className="text-green-400 hover:text-green-300"
|
||||||
setIsEditing(false)
|
>
|
||||||
setEditTitle(task.title)
|
<Check size={16} />
|
||||||
}}
|
</button>
|
||||||
className="text-gray-400 hover:text-gray-300"
|
<button
|
||||||
>
|
onClick={() => {
|
||||||
<X size={16} />
|
setIsEditing(false)
|
||||||
</button>
|
setEditTitle(task.title)
|
||||||
</div>
|
}}
|
||||||
) : (
|
className="text-gray-400 hover:text-gray-300"
|
||||||
<>
|
>
|
||||||
<div className="flex justify-between items-start">
|
<X size={16} />
|
||||||
<div className="flex-1">
|
</button>
|
||||||
<div className="text-gray-200 text-sm">{task.title}</div>
|
|
||||||
{parentTask && (
|
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
|
||||||
↳ subtask of: <span className="text-cyber-orange">{parentTask.title}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsEditing(true)}
|
|
||||||
className="text-gray-400 hover:text-gray-200"
|
|
||||||
>
|
|
||||||
<Edit2 size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="text-gray-600 hover:text-red-400"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Expand/collapse for parent cards */}
|
||||||
|
{isParent && childrenInColumn.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={toggleExpanded}
|
||||||
|
className="text-cyber-orange hover:text-cyber-orange-bright"
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{/* Flag indicator */}
|
||||||
|
{task.flag_color && FLAG_COLORS[task.flag_color] && (
|
||||||
|
<Flag size={12} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" />
|
||||||
|
)}
|
||||||
|
<span className={`${isParent ? 'font-semibold text-cyber-orange' : 'text-gray-200'}`}>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Parent card info: show subtask count in this column */}
|
||||||
|
{isParent && (
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
{childrenInColumn.length} of {totalChildren} subtask{totalChildren !== 1 ? 's' : ''} in this column
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata row */}
|
||||||
|
{(formatTimeWithTotal(task, allTasks) || (task.tags && task.tags.length > 0)) && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
{/* Time estimate */}
|
||||||
|
{formatTimeWithTotal(task, allTasks) && (
|
||||||
|
<div className={`flex items-center gap-1 text-xs text-gray-500 ${task.status === 'done' ? 'line-through' : ''}`}>
|
||||||
|
<Clock size={11} />
|
||||||
|
<span>{formatTimeWithTotal(task, allTasks)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{task.tags && task.tags.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{task.tags.map((tag, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="inline-flex items-center px-1.5 py-0.5 text-xs bg-cyber-orange/20 text-cyber-orange border border-cyber-orange/30 rounded"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{task.description && (
|
||||||
|
<div className="mt-2 text-xs text-gray-400 italic">
|
||||||
|
{task.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
{childrenInColumn.map(child => (
|
||||||
|
<TaskCard
|
||||||
|
key={child.id}
|
||||||
|
task={child}
|
||||||
|
allTasks={allTasks}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
isParent={false}
|
||||||
|
columnStatus={columnStatus}
|
||||||
|
expandedCards={expandedCards}
|
||||||
|
setExpandedCards={setExpandedCards}
|
||||||
|
projectStatuses={projectStatuses}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, onDragOver }) {
|
function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards, projectStatuses }) {
|
||||||
const [showAddTask, setShowAddTask] = useState(false)
|
const [showAddTask, setShowAddTask] = useState(false)
|
||||||
const [newTaskTitle, setNewTaskTitle] = useState('')
|
|
||||||
|
|
||||||
const handleAddTask = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!newTaskTitle.trim()) return
|
|
||||||
|
|
||||||
|
const handleAddTask = async (taskData) => {
|
||||||
try {
|
try {
|
||||||
await createTask({
|
await createTask({
|
||||||
project_id: parseInt(projectId),
|
project_id: parseInt(projectId),
|
||||||
parent_task_id: null,
|
parent_task_id: null,
|
||||||
title: newTaskTitle,
|
title: taskData.title,
|
||||||
status: status.key
|
description: taskData.description,
|
||||||
|
status: taskData.status,
|
||||||
|
tags: taskData.tags,
|
||||||
|
estimated_minutes: taskData.estimated_minutes,
|
||||||
|
flag_color: taskData.flag_color
|
||||||
})
|
})
|
||||||
setNewTaskTitle('')
|
|
||||||
setShowAddTask(false)
|
setShowAddTask(false)
|
||||||
onUpdate()
|
onUpdate()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -129,16 +313,37 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get tasks to display in this column:
|
||||||
|
// 1. All leaf tasks (no children) with this status
|
||||||
|
// 2. All parent tasks that have at least one descendant with this status
|
||||||
|
const leafTasks = allTasks.filter(t => {
|
||||||
|
const hasChildren = allTasks.some(child => child.parent_task_id === t.id)
|
||||||
|
return !hasChildren && t.status === status.key
|
||||||
|
})
|
||||||
|
|
||||||
|
const parentTasks = allTasks.filter(t => {
|
||||||
|
const hasChildren = allTasks.some(child => child.parent_task_id === t.id)
|
||||||
|
return hasChildren && hasDescendantsInStatus(t.id, allTasks, status.key)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Only show root-level parents (not nested parents)
|
||||||
|
const rootParents = parentTasks.filter(t => !t.parent_task_id)
|
||||||
|
|
||||||
|
// Only show root-level leaf tasks (leaf tasks without parents)
|
||||||
|
const rootLeafTasks = leafTasks.filter(t => !t.parent_task_id)
|
||||||
|
|
||||||
|
const displayTasks = [...rootParents, ...rootLeafTasks]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex-1 min-w-[280px] bg-cyber-darker rounded-lg p-4 border-t-4 ${status.color}"
|
className={`flex-1 min-w-[280px] bg-cyber-darker rounded-lg p-4 border-t-4 ${status.color}`}
|
||||||
onDrop={(e) => onDrop(e, status.key)}
|
onDrop={(e) => onDrop(e, status.key)}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="font-semibold text-gray-200">
|
<h3 className="font-semibold text-gray-200">
|
||||||
{status.label}
|
{status.label}
|
||||||
<span className="ml-2 text-xs text-gray-500">({tasks.length})</span>
|
<span className="ml-2 text-xs text-gray-500">({displayTasks.length})</span>
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddTask(true)}
|
onClick={() => setShowAddTask(true)}
|
||||||
@@ -150,55 +355,56 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
|
|||||||
|
|
||||||
{showAddTask && (
|
{showAddTask && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<form onSubmit={handleAddTask}>
|
<TaskForm
|
||||||
<input
|
onSubmit={handleAddTask}
|
||||||
type="text"
|
onCancel={() => setShowAddTask(false)}
|
||||||
value={newTaskTitle}
|
submitLabel="Add Task"
|
||||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
projectStatuses={projectStatuses}
|
||||||
placeholder="Task title..."
|
defaultStatus={status.key}
|
||||||
className="w-full px-2 py-2 bg-cyber-darkest border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange mb-2"
|
/>
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="px-3 py-1 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright text-sm font-semibold"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAddTask(false)}
|
|
||||||
className="px-3 py-1 text-gray-400 hover:text-gray-200 text-sm"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{tasks.map(task => (
|
{displayTasks.map(task => {
|
||||||
<TaskCard
|
const isParent = allTasks.some(t => t.parent_task_id === task.id)
|
||||||
key={task.id}
|
return (
|
||||||
task={task}
|
<TaskCard
|
||||||
allTasks={allTasks}
|
key={task.id}
|
||||||
onUpdate={onUpdate}
|
task={task}
|
||||||
onDragStart={(e, task) => {
|
allTasks={allTasks}
|
||||||
e.dataTransfer.setData('taskId', task.id.toString())
|
onUpdate={onUpdate}
|
||||||
}}
|
onDragStart={(e, task, isParent) => {
|
||||||
/>
|
e.dataTransfer.setData('taskId', task.id.toString())
|
||||||
))}
|
e.dataTransfer.setData('isParent', isParent.toString())
|
||||||
|
}}
|
||||||
|
isParent={isParent}
|
||||||
|
columnStatus={status.key}
|
||||||
|
expandedCards={expandedCards}
|
||||||
|
setExpandedCards={setExpandedCards}
|
||||||
|
projectStatuses={projectStatuses}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanView({ projectId }) {
|
function KanbanView({ projectId, project }) {
|
||||||
const [allTasks, setAllTasks] = useState([])
|
const [allTasks, setAllTasks] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [expandedCards, setExpandedCards] = useState({})
|
||||||
|
|
||||||
|
// Get statuses from project, or use defaults
|
||||||
|
const statuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
|
||||||
|
const statusesWithMeta = statuses.map(status => ({
|
||||||
|
key: status,
|
||||||
|
label: formatStatusLabel(status),
|
||||||
|
color: getStatusColor(status)
|
||||||
|
}))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTasks()
|
loadTasks()
|
||||||
@@ -216,6 +422,19 @@ function KanbanView({ projectId }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExpandAll = () => {
|
||||||
|
const parentTasks = allTasks.filter(t => allTasks.some(child => child.parent_task_id === t.id))
|
||||||
|
const newExpandedState = {}
|
||||||
|
parentTasks.forEach(task => {
|
||||||
|
newExpandedState[task.id] = true
|
||||||
|
})
|
||||||
|
setExpandedCards(newExpandedState)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCollapseAll = () => {
|
||||||
|
setExpandedCards({})
|
||||||
|
}
|
||||||
|
|
||||||
const handleDragOver = (e) => {
|
const handleDragOver = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
@@ -223,11 +442,22 @@ function KanbanView({ projectId }) {
|
|||||||
const handleDrop = async (e, newStatus) => {
|
const handleDrop = async (e, newStatus) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const taskId = parseInt(e.dataTransfer.getData('taskId'))
|
const taskId = parseInt(e.dataTransfer.getData('taskId'))
|
||||||
|
const isParent = e.dataTransfer.getData('isParent') === 'true'
|
||||||
|
|
||||||
if (!taskId) return
|
if (!taskId) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Update the dragged task
|
||||||
await updateTask(taskId, { status: newStatus })
|
await updateTask(taskId, { status: newStatus })
|
||||||
|
|
||||||
|
// If it's a parent task, update all descendants
|
||||||
|
if (isParent) {
|
||||||
|
const descendants = getAllDescendants(taskId, allTasks)
|
||||||
|
for (const descendant of descendants) {
|
||||||
|
await updateTask(descendant.id, { status: newStatus })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadTasks()
|
loadTasks()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`Error: ${err.message}`)
|
alert(`Error: ${err.message}`)
|
||||||
@@ -244,19 +474,39 @@ function KanbanView({ projectId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold text-gray-300 mb-4">Kanban Board</h3>
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-300">Kanban Board (Nested View)</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleExpandAll}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 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"
|
||||||
|
>
|
||||||
|
<ChevronsDown size={16} />
|
||||||
|
Expand All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCollapseAll}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 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"
|
||||||
|
>
|
||||||
|
<ChevronsUp size={16} />
|
||||||
|
Collapse All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
{STATUSES.map(status => (
|
{statusesWithMeta.map(status => (
|
||||||
<KanbanColumn
|
<KanbanColumn
|
||||||
key={status.key}
|
key={status.key}
|
||||||
status={status}
|
status={status}
|
||||||
tasks={allTasks.filter(t => t.status === status.key)}
|
|
||||||
allTasks={allTasks}
|
allTasks={allTasks}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onUpdate={loadTasks}
|
onUpdate={loadTasks}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
|
expandedCards={expandedCards}
|
||||||
|
setExpandedCards={setExpandedCards}
|
||||||
|
projectStatuses={statuses}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
298
frontend/src/components/ProjectSettings.jsx
Normal file
298
frontend/src/components/ProjectSettings.jsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { X, GripVertical, Plus, Trash2, Check, AlertTriangle } from 'lucide-react'
|
||||||
|
import { updateProject, getProjectTasks } from '../utils/api'
|
||||||
|
|
||||||
|
function ProjectSettings({ project, onClose, onUpdate }) {
|
||||||
|
const [statuses, setStatuses] = useState(project.statuses || [])
|
||||||
|
const [editingIndex, setEditingIndex] = useState(null)
|
||||||
|
const [editingValue, setEditingValue] = useState('')
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [taskCounts, setTaskCounts] = useState({})
|
||||||
|
const [deleteWarning, setDeleteWarning] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTaskCounts()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadTaskCounts = async () => {
|
||||||
|
try {
|
||||||
|
const tasks = await getProjectTasks(project.id)
|
||||||
|
const counts = {}
|
||||||
|
statuses.forEach(status => {
|
||||||
|
counts[status] = tasks.filter(t => t.status === status).length
|
||||||
|
})
|
||||||
|
setTaskCounts(counts)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load task counts:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragStart = (index) => {
|
||||||
|
setDraggedIndex(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (e, index) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (draggedIndex === null || draggedIndex === index) return
|
||||||
|
|
||||||
|
const newStatuses = [...statuses]
|
||||||
|
const draggedItem = newStatuses[draggedIndex]
|
||||||
|
newStatuses.splice(draggedIndex, 1)
|
||||||
|
newStatuses.splice(index, 0, draggedItem)
|
||||||
|
|
||||||
|
setStatuses(newStatuses)
|
||||||
|
setDraggedIndex(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggedIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddStatus = () => {
|
||||||
|
const newStatus = `new_status_${Date.now()}`
|
||||||
|
setStatuses([...statuses, newStatus])
|
||||||
|
setEditingIndex(statuses.length)
|
||||||
|
setEditingValue(newStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartEdit = (index) => {
|
||||||
|
setEditingIndex(index)
|
||||||
|
setEditingValue(statuses[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveEdit = () => {
|
||||||
|
if (!editingValue.trim()) {
|
||||||
|
setError('Status name cannot be empty')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedValue = editingValue.trim().toLowerCase().replace(/\s+/g, '_')
|
||||||
|
|
||||||
|
if (statuses.some((s, i) => i !== editingIndex && s === trimmedValue)) {
|
||||||
|
setError('Status name already exists')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatuses = [...statuses]
|
||||||
|
newStatuses[editingIndex] = trimmedValue
|
||||||
|
setStatuses(newStatuses)
|
||||||
|
setEditingIndex(null)
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
// If it's a new status that was never saved, remove it
|
||||||
|
if (statuses[editingIndex].startsWith('new_status_')) {
|
||||||
|
const newStatuses = statuses.filter((_, i) => i !== editingIndex)
|
||||||
|
setStatuses(newStatuses)
|
||||||
|
}
|
||||||
|
setEditingIndex(null)
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteStatus = (index) => {
|
||||||
|
const statusToDelete = statuses[index]
|
||||||
|
const taskCount = taskCounts[statusToDelete] || 0
|
||||||
|
|
||||||
|
if (taskCount > 0) {
|
||||||
|
setDeleteWarning({ index, status: statusToDelete, count: taskCount })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statuses.length === 1) {
|
||||||
|
setError('Cannot delete the last status')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatuses = statuses.filter((_, i) => i !== index)
|
||||||
|
setStatuses(newStatuses)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (statuses.length === 0) {
|
||||||
|
setError('Project must have at least one status')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingIndex !== null) {
|
||||||
|
setError('Please save or cancel the status you are editing')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateProject(project.id, { statuses })
|
||||||
|
onUpdate()
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center p-6 border-b border-cyber-orange/20">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-100">Project Settings</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-200 mb-2">Project Details</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-400">Name:</span>
|
||||||
|
<span className="ml-2 text-gray-200">{project.name}</span>
|
||||||
|
</div>
|
||||||
|
{project.description && (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-400">Description:</span>
|
||||||
|
<span className="ml-2 text-gray-200">{project.description}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-200 mb-2">Status Workflow</h3>
|
||||||
|
<p className="text-sm text-gray-400 mb-4">
|
||||||
|
Drag to reorder, click to rename. Tasks will appear in Kanban columns in this order.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{statuses.map((status, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
draggable={editingIndex !== index}
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className={`flex items-center gap-3 p-3 bg-cyber-darker border border-cyber-orange/30 rounded ${
|
||||||
|
draggedIndex === index ? 'opacity-50' : ''
|
||||||
|
} ${editingIndex !== index ? 'cursor-move' : ''}`}
|
||||||
|
>
|
||||||
|
{editingIndex !== index && (
|
||||||
|
<GripVertical size={18} className="text-gray-500" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingIndex === index ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingValue}
|
||||||
|
onChange={(e) => setEditingValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSaveEdit()
|
||||||
|
if (e.key === 'Escape') handleCancelEdit()
|
||||||
|
}}
|
||||||
|
className="flex-1 px-2 py-1 bg-cyber-darkest border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveEdit}
|
||||||
|
className="text-green-400 hover:text-green-300"
|
||||||
|
>
|
||||||
|
<Check size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className="text-gray-400 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartEdit(index)}
|
||||||
|
className="flex-1 text-left text-gray-200 hover:text-cyber-orange"
|
||||||
|
>
|
||||||
|
{status.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{taskCounts[status] || 0} task{taskCounts[status] === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteStatus(index)}
|
||||||
|
className="text-gray-400 hover:text-red-400"
|
||||||
|
disabled={statuses.length === 1}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleAddStatus}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm bg-cyber-darker border border-cyber-orange/30 text-gray-300 rounded hover:border-cyber-orange/60 hover:bg-cyber-dark transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add Status
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-3 p-6 border-t border-cyber-orange/20">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-gray-400 hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Warning Dialog */}
|
||||||
|
{deleteWarning && (
|
||||||
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||||
|
<div className="bg-cyber-darkest border border-red-500/50 rounded-lg p-6 max-w-md">
|
||||||
|
<div className="flex items-start gap-3 mb-4">
|
||||||
|
<AlertTriangle className="text-red-400 flex-shrink-0" size={24} />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-100 mb-2">Cannot Delete Status</h3>
|
||||||
|
<p className="text-sm text-gray-300">
|
||||||
|
The status "{deleteWarning.status}" has {deleteWarning.count} task{deleteWarning.count === 1 ? '' : 's'}.
|
||||||
|
Please move or delete those tasks first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteWarning(null)}
|
||||||
|
className="px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectSettings
|
||||||
263
frontend/src/components/SearchBar.jsx
Normal file
263
frontend/src/components/SearchBar.jsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Search, X, Flag } from 'lucide-react'
|
||||||
|
import { searchTasks, getProjects } from '../utils/api'
|
||||||
|
import { formatTime } from '../utils/format'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
const FLAG_COLORS = {
|
||||||
|
red: 'text-red-500',
|
||||||
|
orange: 'text-orange-500',
|
||||||
|
yellow: 'text-yellow-500',
|
||||||
|
green: 'text-green-500',
|
||||||
|
blue: 'text-blue-500',
|
||||||
|
purple: 'text-purple-500',
|
||||||
|
pink: 'text-pink-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchBar() {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState([])
|
||||||
|
const [projects, setProjects] = useState([])
|
||||||
|
const [selectedProjects, setSelectedProjects] = useState([])
|
||||||
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
|
const [showResults, setShowResults] = useState(false)
|
||||||
|
const [showProjectFilter, setShowProjectFilter] = useState(false)
|
||||||
|
const searchRef = useRef(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProjects()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event) {
|
||||||
|
if (searchRef.current && !searchRef.current.contains(event.target)) {
|
||||||
|
setShowResults(false)
|
||||||
|
setShowProjectFilter(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadProjects = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getProjects()
|
||||||
|
setProjects(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load projects:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = async (searchQuery) => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setResults([])
|
||||||
|
setShowResults(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true)
|
||||||
|
try {
|
||||||
|
const projectIds = selectedProjects.length > 0 ? selectedProjects : null
|
||||||
|
const data = await searchTasks(searchQuery, projectIds)
|
||||||
|
setResults(data)
|
||||||
|
setShowResults(true)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Search failed:', err)
|
||||||
|
setResults([])
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQueryChange = (e) => {
|
||||||
|
const newQuery = e.target.value
|
||||||
|
setQuery(newQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced search effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setResults([])
|
||||||
|
setShowResults(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const debounceMs = parseInt(import.meta.env.VITE_SEARCH_DEBOUNCE_MS || '300')
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
handleSearch(query)
|
||||||
|
}, debounceMs)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId)
|
||||||
|
}, [query, selectedProjects])
|
||||||
|
|
||||||
|
const toggleProjectFilter = (projectId) => {
|
||||||
|
setSelectedProjects(prev => {
|
||||||
|
if (prev.includes(projectId)) {
|
||||||
|
return prev.filter(id => id !== projectId)
|
||||||
|
} else {
|
||||||
|
return [...prev, projectId]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTaskClick = (task) => {
|
||||||
|
navigate(`/project/${task.project_id}`)
|
||||||
|
setShowResults(false)
|
||||||
|
setQuery('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
setQuery('')
|
||||||
|
setResults([])
|
||||||
|
setShowResults(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={searchRef}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={handleQueryChange}
|
||||||
|
onFocus={() => query && setShowResults(true)}
|
||||||
|
placeholder="Search tasks..."
|
||||||
|
className="w-64 pl-9 pr-8 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange placeholder-gray-500"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
onClick={clearSearch}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Filter Button */}
|
||||||
|
{projects.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowProjectFilter(!showProjectFilter)}
|
||||||
|
className={`px-3 py-2 text-sm rounded border ${
|
||||||
|
selectedProjects.length > 0
|
||||||
|
? 'bg-cyber-orange/20 border-cyber-orange text-cyber-orange'
|
||||||
|
: 'bg-cyber-darker border-cyber-orange/30 text-gray-400'
|
||||||
|
} hover:border-cyber-orange transition-colors`}
|
||||||
|
>
|
||||||
|
{selectedProjects.length > 0 ? `${selectedProjects.length} Project(s)` : 'All Projects'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Filter Dropdown */}
|
||||||
|
{showProjectFilter && (
|
||||||
|
<div className="absolute top-12 right-0 z-50 w-64 bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-lg max-h-80 overflow-y-auto">
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="text-xs text-gray-400 px-2 py-1 mb-1">Filter by projects:</div>
|
||||||
|
{projects.map(project => (
|
||||||
|
<label
|
||||||
|
key={project.id}
|
||||||
|
className="flex items-center gap-2 px-2 py-2 hover:bg-cyber-darker rounded cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedProjects.includes(project.id)}
|
||||||
|
onChange={() => toggleProjectFilter(project.id)}
|
||||||
|
className="rounded border-cyber-orange/50 bg-cyber-darker text-cyber-orange focus:ring-cyber-orange focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-300">{project.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{selectedProjects.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProjects([])
|
||||||
|
if (query) handleSearch(query)
|
||||||
|
}}
|
||||||
|
className="w-full mt-2 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 border border-gray-600 rounded"
|
||||||
|
>
|
||||||
|
Clear Filter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search Results */}
|
||||||
|
{showResults && (
|
||||||
|
<div className="absolute top-12 left-0 z-50 w-96 bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-lg max-h-96 overflow-y-auto">
|
||||||
|
{isSearching ? (
|
||||||
|
<div className="p-4 text-center text-gray-400 text-sm">Searching...</div>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-gray-500 text-sm">No results found</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="text-xs text-gray-400 px-2 py-1 mb-1">
|
||||||
|
{results.length} result{results.length !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
{results.map(task => {
|
||||||
|
const project = projects.find(p => p.id === task.project_id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={task.id}
|
||||||
|
onClick={() => handleTaskClick(task)}
|
||||||
|
className="w-full text-left px-2 py-2 hover:bg-cyber-darker rounded transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{/* Flag */}
|
||||||
|
{task.flag_color && FLAG_COLORS[task.flag_color] && (
|
||||||
|
<Flag size={12} className={`mt-0.5 ${FLAG_COLORS[task.flag_color]}`} fill="currentColor" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Title */}
|
||||||
|
<div className="text-sm text-gray-200 truncate">{task.title}</div>
|
||||||
|
|
||||||
|
{/* Project name */}
|
||||||
|
{project && (
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">
|
||||||
|
in: <span className="text-cyber-orange">{project.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
{(task.estimated_minutes || (task.tags && task.tags.length > 0)) && (
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{task.estimated_minutes && (
|
||||||
|
<span className="text-xs text-gray-500">{formatTime(task.estimated_minutes)}</span>
|
||||||
|
)}
|
||||||
|
{task.tags && task.tags.length > 0 && (
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{task.tags.slice(0, 3).map((tag, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="inline-block px-1 py-0.5 text-xs bg-cyber-orange/20 text-cyber-orange border border-cyber-orange/30 rounded"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{task.tags.length > 3 && (
|
||||||
|
<span className="text-xs text-gray-500">+{task.tags.length - 3}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchBar
|
||||||
182
frontend/src/components/TaskForm.jsx
Normal file
182
frontend/src/components/TaskForm.jsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Flag } from 'lucide-react'
|
||||||
|
|
||||||
|
const FLAG_COLORS = [
|
||||||
|
{ name: null, label: 'None', color: 'bg-gray-700' },
|
||||||
|
{ name: 'red', label: 'Red', color: 'bg-red-500' },
|
||||||
|
{ name: 'orange', label: 'Orange', color: 'bg-orange-500' },
|
||||||
|
{ name: 'yellow', label: 'Yellow', color: 'bg-yellow-500' },
|
||||||
|
{ name: 'green', label: 'Green', color: 'bg-green-500' },
|
||||||
|
{ name: 'blue', label: 'Blue', color: 'bg-blue-500' },
|
||||||
|
{ name: 'purple', label: 'Purple', color: 'bg-purple-500' },
|
||||||
|
{ name: 'pink', label: 'Pink', color: 'bg-pink-500' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
if (!title.trim()) return
|
||||||
|
|
||||||
|
// Convert hours and minutes to total minutes
|
||||||
|
const totalMinutes = (parseInt(hours) || 0) * 60 + (parseInt(minutes) || 0)
|
||||||
|
|
||||||
|
// Parse tags
|
||||||
|
const tagList = tags
|
||||||
|
? tags.split(',').map(t => t.trim()).filter(t => t.length > 0)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const taskData = {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
tags: tagList && tagList.length > 0 ? tagList : null,
|
||||||
|
estimated_minutes: totalMinutes > 0 ? totalMinutes : null,
|
||||||
|
flag_color: flagColor,
|
||||||
|
status: status
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(taskData)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-cyber-darkest border border-cyber-orange/30 rounded-lg p-4 space-y-3">
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Task Title *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Enter task title..."
|
||||||
|
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"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Optional task description..."
|
||||||
|
rows="3"
|
||||||
|
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 resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Tags (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)}
|
||||||
|
placeholder="coding, bug-fix, frontend"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Estimate */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Time Estimate</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={hours}
|
||||||
|
onChange={(e) => setHours(e.target.value)}
|
||||||
|
placeholder="Hours"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
value={minutes}
|
||||||
|
onChange={(e) => setMinutes(e.target.value)}
|
||||||
|
placeholder="Minutes"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||||
|
>
|
||||||
|
{statuses.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{formatStatusLabel(s)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flag Color */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Flag Color</label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{FLAG_COLORS.map(({ name, label, color }) => (
|
||||||
|
<button
|
||||||
|
key={name || 'none'}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFlagColor(name)}
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-all ${
|
||||||
|
flagColor === name
|
||||||
|
? 'bg-cyber-orange/20 border-2 border-cyber-orange'
|
||||||
|
: 'border-2 border-transparent hover:border-cyber-orange/40'
|
||||||
|
}`}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<div className={`w-4 h-4 ${color} rounded`} />
|
||||||
|
{flagColor === name && '✓'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold text-sm transition-colors"
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-gray-400 hover:text-gray-200 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskForm
|
||||||
405
frontend/src/components/TaskMenu.jsx
Normal file
405
frontend/src/components/TaskMenu.jsx
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText } from 'lucide-react'
|
||||||
|
import { updateTask } from '../utils/api'
|
||||||
|
|
||||||
|
const FLAG_COLORS = [
|
||||||
|
{ name: 'red', color: 'bg-red-500' },
|
||||||
|
{ name: 'orange', color: 'bg-orange-500' },
|
||||||
|
{ name: 'yellow', color: 'bg-yellow-500' },
|
||||||
|
{ name: 'green', color: 'bg-green-500' },
|
||||||
|
{ name: 'blue', color: 'bg-blue-500' },
|
||||||
|
{ name: 'purple', color: 'bg-purple-500' },
|
||||||
|
{ name: 'pink', color: 'bg-pink-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
|
||||||
|
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)
|
||||||
|
const [showTagsEdit, setShowTagsEdit] = useState(false)
|
||||||
|
const [showFlagEdit, setShowFlagEdit] = useState(false)
|
||||||
|
const [showStatusEdit, setShowStatusEdit] = useState(false)
|
||||||
|
|
||||||
|
// Calculate hours and minutes from task.estimated_minutes
|
||||||
|
const initialHours = task.estimated_minutes ? Math.floor(task.estimated_minutes / 60) : ''
|
||||||
|
const initialMinutes = task.estimated_minutes ? task.estimated_minutes % 60 : ''
|
||||||
|
|
||||||
|
const [editHours, setEditHours] = useState(initialHours)
|
||||||
|
const [editMinutes, setEditMinutes] = useState(initialMinutes)
|
||||||
|
const [editDescription, setEditDescription] = useState(task.description || '')
|
||||||
|
const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '')
|
||||||
|
const menuRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event) {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
setShowTimeEdit(false)
|
||||||
|
setShowDescriptionEdit(false)
|
||||||
|
setShowTagsEdit(false)
|
||||||
|
setShowFlagEdit(false)
|
||||||
|
setShowStatusEdit(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleUpdateTime = async () => {
|
||||||
|
try {
|
||||||
|
const totalMinutes = (parseInt(editHours) || 0) * 60 + (parseInt(editMinutes) || 0)
|
||||||
|
const minutes = totalMinutes > 0 ? totalMinutes : null
|
||||||
|
await updateTask(task.id, { estimated_minutes: minutes })
|
||||||
|
setShowTimeEdit(false)
|
||||||
|
setIsOpen(false)
|
||||||
|
onUpdate()
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateDescription = async () => {
|
||||||
|
try {
|
||||||
|
const description = editDescription.trim() || null
|
||||||
|
await updateTask(task.id, { description })
|
||||||
|
setShowDescriptionEdit(false)
|
||||||
|
setIsOpen(false)
|
||||||
|
onUpdate()
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateTags = async () => {
|
||||||
|
try {
|
||||||
|
const tags = editTags
|
||||||
|
? editTags.split(',').map(t => t.trim()).filter(t => t.length > 0)
|
||||||
|
: null
|
||||||
|
await updateTask(task.id, { tags })
|
||||||
|
setShowTagsEdit(false)
|
||||||
|
setIsOpen(false)
|
||||||
|
onUpdate()
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateFlag = async (color) => {
|
||||||
|
try {
|
||||||
|
await updateTask(task.id, { flag_color: color })
|
||||||
|
setShowFlagEdit(false)
|
||||||
|
setIsOpen(false)
|
||||||
|
onUpdate()
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearFlag = async () => {
|
||||||
|
try {
|
||||||
|
await updateTask(task.id, { flag_color: null })
|
||||||
|
setShowFlagEdit(false)
|
||||||
|
setIsOpen(false)
|
||||||
|
onUpdate()
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateStatus = async (newStatus) => {
|
||||||
|
try {
|
||||||
|
await updateTask(task.id, { status: newStatus })
|
||||||
|
setShowStatusEdit(false)
|
||||||
|
setIsOpen(false)
|
||||||
|
onUpdate()
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsOpen(!isOpen)
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-gray-200 p-1"
|
||||||
|
title="More options"
|
||||||
|
>
|
||||||
|
<MoreVertical size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<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 */}
|
||||||
|
{showTimeEdit ? (
|
||||||
|
<div className="p-3 border-b border-cyber-orange/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Clock size={14} className="text-cyber-orange" />
|
||||||
|
<span className="text-sm text-gray-300">Time Estimate</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={editHours}
|
||||||
|
onChange={(e) => setEditHours(e.target.value)}
|
||||||
|
placeholder="Hours"
|
||||||
|
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
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
value={editMinutes}
|
||||||
|
onChange={(e) => setEditMinutes(e.target.value)}
|
||||||
|
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"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleUpdateTime}
|
||||||
|
className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTimeEdit(false)}
|
||||||
|
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowTimeEdit(true)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
|
>
|
||||||
|
<Clock size={14} />
|
||||||
|
<span>Set Time Estimate</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description Edit */}
|
||||||
|
{showDescriptionEdit ? (
|
||||||
|
<div className="p-3 border-b border-cyber-orange/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FileText size={14} className="text-cyber-orange" />
|
||||||
|
<span className="text-sm text-gray-300">Description</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
value={editDescription}
|
||||||
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
|
placeholder="Task description..."
|
||||||
|
rows="4"
|
||||||
|
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
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleUpdateDescription}
|
||||||
|
className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDescriptionEdit(false)}
|
||||||
|
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowDescriptionEdit(true)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
|
>
|
||||||
|
<FileText size={14} />
|
||||||
|
<span>Edit Description</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags Edit */}
|
||||||
|
{showTagsEdit ? (
|
||||||
|
<div className="p-3 border-b border-cyber-orange/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Tag size={14} className="text-cyber-orange" />
|
||||||
|
<span className="text-sm text-gray-300">Tags (comma-separated)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editTags}
|
||||||
|
onChange={(e) => setEditTags(e.target.value)}
|
||||||
|
placeholder="coding, bug-fix"
|
||||||
|
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
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleUpdateTags}
|
||||||
|
className="text-green-400 hover:text-green-300"
|
||||||
|
>
|
||||||
|
<Check size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTagsEdit(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowTagsEdit(true)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
|
>
|
||||||
|
<Tag size={14} />
|
||||||
|
<span>Edit Tags</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Flag Color Edit */}
|
||||||
|
{showFlagEdit ? (
|
||||||
|
<div className="p-3 border-b border-cyber-orange/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Flag size={14} className="text-cyber-orange" />
|
||||||
|
<span className="text-sm text-gray-300">Flag Color</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{FLAG_COLORS.map(({ name, color }) => (
|
||||||
|
<button
|
||||||
|
key={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}
|
||||||
|
className="w-full mt-2 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 border border-gray-600 rounded"
|
||||||
|
>
|
||||||
|
Clear Flag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowFlagEdit(true)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
|
>
|
||||||
|
<Flag size={14} />
|
||||||
|
<span>Set Flag Color</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Change */}
|
||||||
|
{showStatusEdit ? (
|
||||||
|
<div className="p-3 border-b border-cyber-orange/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<ListTodo size={14} className="text-cyber-orange" />
|
||||||
|
<span className="text-sm text-gray-300">Change Status</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map((status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => handleUpdateStatus(status)}
|
||||||
|
className={`w-full text-left px-2 py-1.5 rounded text-sm ${
|
||||||
|
task.status === status
|
||||||
|
? 'bg-cyber-orange/20 border border-cyber-orange/40'
|
||||||
|
: 'hover:bg-cyber-darker border border-transparent'
|
||||||
|
} ${getStatusTextColor(status)} transition-all`}
|
||||||
|
>
|
||||||
|
{formatStatusLabel(status)} {task.status === status && '✓'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowStatusEdit(true)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
|
>
|
||||||
|
<ListTodo size={14} />
|
||||||
|
<span>Change Status</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Title */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onEdit()
|
||||||
|
setIsOpen(false)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||||
|
>
|
||||||
|
<Edit2 size={14} />
|
||||||
|
<span>Edit Title</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskMenu
|
||||||
@@ -3,10 +3,10 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Plus,
|
Plus,
|
||||||
Edit2,
|
|
||||||
Trash2,
|
|
||||||
Check,
|
Check,
|
||||||
X
|
X,
|
||||||
|
Flag,
|
||||||
|
Clock
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
getProjectTaskTree,
|
getProjectTaskTree,
|
||||||
@@ -14,28 +14,42 @@ import {
|
|||||||
updateTask,
|
updateTask,
|
||||||
deleteTask
|
deleteTask
|
||||||
} from '../utils/api'
|
} from '../utils/api'
|
||||||
|
import { formatTimeWithTotal } from '../utils/format'
|
||||||
|
import TaskMenu from './TaskMenu'
|
||||||
|
import TaskForm from './TaskForm'
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
// Helper to format status label
|
||||||
backlog: 'text-gray-400',
|
const formatStatusLabel = (status) => {
|
||||||
in_progress: 'text-blue-400',
|
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||||
blocked: 'text-red-400',
|
|
||||||
done: 'text-green-400'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
// Helper to get status color
|
||||||
backlog: 'Backlog',
|
const getStatusColor = (status) => {
|
||||||
in_progress: 'In Progress',
|
const lowerStatus = status.toLowerCase()
|
||||||
blocked: 'Blocked',
|
if (lowerStatus === 'backlog') return 'text-gray-400'
|
||||||
done: 'Done'
|
if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'text-blue-400'
|
||||||
|
if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'text-yellow-400'
|
||||||
|
if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'text-green-400'
|
||||||
|
if (lowerStatus.includes('blocked')) return 'text-red-400'
|
||||||
|
return 'text-purple-400' // default for custom statuses
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
const FLAG_COLORS = {
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
|
||||||
const [isExpanded, setIsExpanded] = useState(true)
|
const [isExpanded, setIsExpanded] = useState(true)
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [editTitle, setEditTitle] = useState(task.title)
|
const [editTitle, setEditTitle] = useState(task.title)
|
||||||
const [editStatus, setEditStatus] = useState(task.status)
|
const [editStatus, setEditStatus] = useState(task.status)
|
||||||
const [showAddSubtask, setShowAddSubtask] = useState(false)
|
const [showAddSubtask, setShowAddSubtask] = useState(false)
|
||||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('')
|
|
||||||
|
|
||||||
const hasSubtasks = task.subtasks && task.subtasks.length > 0
|
const hasSubtasks = task.subtasks && task.subtasks.length > 0
|
||||||
|
|
||||||
@@ -62,18 +76,18 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddSubtask = async (e) => {
|
const handleAddSubtask = async (taskData) => {
|
||||||
e.preventDefault()
|
|
||||||
if (!newSubtaskTitle.trim()) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createTask({
|
await createTask({
|
||||||
project_id: parseInt(projectId),
|
project_id: parseInt(projectId),
|
||||||
parent_task_id: task.id,
|
parent_task_id: task.id,
|
||||||
title: newSubtaskTitle,
|
title: taskData.title,
|
||||||
status: 'backlog'
|
description: taskData.description,
|
||||||
|
status: taskData.status,
|
||||||
|
tags: taskData.tags,
|
||||||
|
estimated_minutes: taskData.estimated_minutes,
|
||||||
|
flag_color: taskData.flag_color
|
||||||
})
|
})
|
||||||
setNewSubtaskTitle('')
|
|
||||||
setShowAddSubtask(false)
|
setShowAddSubtask(false)
|
||||||
setIsExpanded(true)
|
setIsExpanded(true)
|
||||||
onUpdate()
|
onUpdate()
|
||||||
@@ -114,10 +128,9 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
onChange={(e) => setEditStatus(e.target.value)}
|
onChange={(e) => setEditStatus(e.target.value)}
|
||||||
className="px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
className="px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||||
>
|
>
|
||||||
<option value="backlog">Backlog</option>
|
{(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map(status => (
|
||||||
<option value="in_progress">In Progress</option>
|
<option key={status} value={status}>{formatStatusLabel(status)}</option>
|
||||||
<option value="blocked">Blocked</option>
|
))}
|
||||||
<option value="done">Done</option>
|
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
@@ -139,10 +152,50 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="text-gray-200">{task.title}</span>
|
<div className="flex items-center gap-2">
|
||||||
<span className={`ml-3 text-xs ${STATUS_COLORS[task.status]}`}>
|
{/* Flag indicator */}
|
||||||
{STATUS_LABELS[task.status]}
|
{task.flag_color && FLAG_COLORS[task.flag_color] && (
|
||||||
</span>
|
<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 ${getStatusColor(task.status)}`}>
|
||||||
|
{formatStatusLabel(task.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata row */}
|
||||||
|
{(formatTimeWithTotal(task) || (task.tags && task.tags.length > 0)) && (
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
{/* Time estimate */}
|
||||||
|
{formatTimeWithTotal(task) && (
|
||||||
|
<div className={`flex items-center gap-1 text-xs text-gray-500 ${task.status === 'done' ? 'line-through' : ''}`}>
|
||||||
|
<Clock size={12} />
|
||||||
|
<span>{formatTimeWithTotal(task)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{task.tags && task.tags.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{task.tags.map((tag, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-cyber-orange/20 text-cyber-orange border border-cyber-orange/30 rounded"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{task.description && (
|
||||||
|
<div className="mt-2 text-xs text-gray-400 italic">
|
||||||
|
{task.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -154,20 +207,13 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<TaskMenu
|
||||||
onClick={() => setIsEditing(true)}
|
task={task}
|
||||||
className="text-gray-400 hover:text-gray-200"
|
onUpdate={onUpdate}
|
||||||
title="Edit"
|
onDelete={handleDelete}
|
||||||
>
|
onEdit={() => setIsEditing(true)}
|
||||||
<Edit2 size={16} />
|
projectStatuses={projectStatuses}
|
||||||
</button>
|
/>
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="text-gray-600 hover:text-red-400"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -176,29 +222,12 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
{/* Add Subtask Form */}
|
{/* Add Subtask Form */}
|
||||||
{showAddSubtask && (
|
{showAddSubtask && (
|
||||||
<div style={{ marginLeft: `${level * 1.5}rem` }} className="mt-2">
|
<div style={{ marginLeft: `${level * 1.5}rem` }} className="mt-2">
|
||||||
<form onSubmit={handleAddSubtask} className="flex gap-2">
|
<TaskForm
|
||||||
<input
|
onSubmit={handleAddSubtask}
|
||||||
type="text"
|
onCancel={() => setShowAddSubtask(false)}
|
||||||
value={newSubtaskTitle}
|
submitLabel="Add Subtask"
|
||||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
projectStatuses={projectStatuses}
|
||||||
placeholder="New subtask title..."
|
/>
|
||||||
className="flex-1 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"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="px-3 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright text-sm font-semibold"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAddSubtask(false)}
|
|
||||||
className="px-3 py-2 text-gray-400 hover:text-gray-200 text-sm"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -212,6 +241,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
level={level + 1}
|
level={level + 1}
|
||||||
|
projectStatuses={projectStatuses}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -220,12 +250,12 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TreeView({ projectId }) {
|
function TreeView({ projectId, project }) {
|
||||||
|
const projectStatuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
|
||||||
const [tasks, setTasks] = useState([])
|
const [tasks, setTasks] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [showAddRoot, setShowAddRoot] = useState(false)
|
const [showAddRoot, setShowAddRoot] = useState(false)
|
||||||
const [newTaskTitle, setNewTaskTitle] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTasks()
|
loadTasks()
|
||||||
@@ -243,18 +273,18 @@ function TreeView({ projectId }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddRootTask = async (e) => {
|
const handleAddRootTask = async (taskData) => {
|
||||||
e.preventDefault()
|
|
||||||
if (!newTaskTitle.trim()) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createTask({
|
await createTask({
|
||||||
project_id: parseInt(projectId),
|
project_id: parseInt(projectId),
|
||||||
parent_task_id: null,
|
parent_task_id: null,
|
||||||
title: newTaskTitle,
|
title: taskData.title,
|
||||||
status: 'backlog'
|
description: taskData.description,
|
||||||
|
status: taskData.status,
|
||||||
|
tags: taskData.tags,
|
||||||
|
estimated_minutes: taskData.estimated_minutes,
|
||||||
|
flag_color: taskData.flag_color
|
||||||
})
|
})
|
||||||
setNewTaskTitle('')
|
|
||||||
setShowAddRoot(false)
|
setShowAddRoot(false)
|
||||||
loadTasks()
|
loadTasks()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -285,29 +315,12 @@ function TreeView({ projectId }) {
|
|||||||
|
|
||||||
{showAddRoot && (
|
{showAddRoot && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<form onSubmit={handleAddRootTask} className="flex gap-2">
|
<TaskForm
|
||||||
<input
|
onSubmit={handleAddRootTask}
|
||||||
type="text"
|
onCancel={() => setShowAddRoot(false)}
|
||||||
value={newTaskTitle}
|
submitLabel="Add Task"
|
||||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
projectStatuses={projectStatuses}
|
||||||
placeholder="New task title..."
|
/>
|
||||||
className="flex-1 px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 focus:outline-none focus:border-cyber-orange"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAddRoot(false)}
|
|
||||||
className="px-4 py-2 text-gray-400 hover:text-gray-200"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -324,6 +337,7 @@ function TreeView({ projectId }) {
|
|||||||
task={task}
|
task={task}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onUpdate={loadTasks}
|
onUpdate={loadTasks}
|
||||||
|
projectStatuses={projectStatuses}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Plus, Upload, Trash2 } from 'lucide-react'
|
import { Plus, Upload, Trash2, Archive, ArchiveRestore } from 'lucide-react'
|
||||||
import { getProjects, createProject, deleteProject, importJSON } from '../utils/api'
|
import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject } from '../utils/api'
|
||||||
|
|
||||||
function ProjectList() {
|
function ProjectList() {
|
||||||
const [projects, setProjects] = useState([])
|
const [projects, setProjects] = useState([])
|
||||||
@@ -12,15 +12,22 @@ function ProjectList() {
|
|||||||
const [newProjectDesc, setNewProjectDesc] = useState('')
|
const [newProjectDesc, setNewProjectDesc] = useState('')
|
||||||
const [importJSON_Text, setImportJSONText] = useState('')
|
const [importJSON_Text, setImportJSONText] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [activeTab, setActiveTab] = useState('active') // 'active', 'archived', 'all'
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProjects()
|
loadProjects()
|
||||||
}, [])
|
}, [activeTab])
|
||||||
|
|
||||||
const loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getProjects()
|
setLoading(true)
|
||||||
|
let archivedFilter = null
|
||||||
|
if (activeTab === 'active') archivedFilter = false
|
||||||
|
if (activeTab === 'archived') archivedFilter = true
|
||||||
|
// 'all' tab uses null to get all projects
|
||||||
|
|
||||||
|
const data = await getProjects(archivedFilter)
|
||||||
setProjects(data)
|
setProjects(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
@@ -72,13 +79,33 @@ function ProjectList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleArchiveProject = async (projectId, e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
try {
|
||||||
|
await archiveProject(projectId)
|
||||||
|
setProjects(projects.filter(p => p.id !== projectId))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnarchiveProject = async (projectId, e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
try {
|
||||||
|
await unarchiveProject(projectId)
|
||||||
|
setProjects(projects.filter(p => p.id !== projectId))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="text-center text-gray-400 py-12">Loading...</div>
|
return <div className="text-center text-gray-400 py-12">Loading...</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h2 className="text-3xl font-bold text-gray-100">Projects</h2>
|
<h2 className="text-3xl font-bold text-gray-100">Projects</h2>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -98,6 +125,40 @@ function ProjectList() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 mb-6 border-b border-cyber-orange/20">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('active')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'active'
|
||||||
|
? 'text-cyber-orange border-b-2 border-cyber-orange'
|
||||||
|
: 'text-gray-400 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('archived')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'archived'
|
||||||
|
? 'text-cyber-orange border-b-2 border-cyber-orange'
|
||||||
|
: 'text-gray-400 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Archived
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'all'
|
||||||
|
? 'text-cyber-orange border-b-2 border-cyber-orange'
|
||||||
|
: 'text-gray-400 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-4 bg-red-900/30 border border-red-500/50 rounded text-red-300">
|
<div className="mb-4 p-4 bg-red-900/30 border border-red-500/50 rounded text-red-300">
|
||||||
{error}
|
{error}
|
||||||
@@ -106,8 +167,14 @@ function ProjectList() {
|
|||||||
|
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<div className="text-center py-16 text-gray-500">
|
<div className="text-center py-16 text-gray-500">
|
||||||
<p className="text-xl mb-2">No projects yet</p>
|
<p className="text-xl mb-2">
|
||||||
<p className="text-sm">Create a new project or import from JSON</p>
|
{activeTab === 'archived' ? 'No archived projects' : 'No projects yet'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{activeTab === 'archived'
|
||||||
|
? 'Archive projects to keep them out of your active workspace'
|
||||||
|
: 'Create a new project or import from JSON'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
@@ -115,18 +182,45 @@ function ProjectList() {
|
|||||||
<div
|
<div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
onClick={() => navigate(`/project/${project.id}`)}
|
onClick={() => navigate(`/project/${project.id}`)}
|
||||||
className="p-6 bg-cyber-darkest border border-cyber-orange/30 rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group"
|
className={`p-6 bg-cyber-darkest border rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group ${
|
||||||
|
project.is_archived
|
||||||
|
? 'border-gray-700 opacity-75'
|
||||||
|
: 'border-cyber-orange/30'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="flex justify-between items-start mb-2">
|
||||||
<h3 className="text-xl font-semibold text-gray-100 group-hover:text-cyber-orange transition-colors">
|
<h3 className="text-xl font-semibold text-gray-100 group-hover:text-cyber-orange transition-colors">
|
||||||
{project.name}
|
{project.name}
|
||||||
|
{project.is_archived && (
|
||||||
|
<span className="ml-2 text-xs text-gray-500">(archived)</span>
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
onClick={(e) => handleDeleteProject(project.id, e)}
|
{project.is_archived ? (
|
||||||
className="text-gray-600 hover:text-red-400 transition-colors"
|
<button
|
||||||
>
|
onClick={(e) => handleUnarchiveProject(project.id, e)}
|
||||||
<Trash2 size={18} />
|
className="text-gray-600 hover:text-cyber-orange transition-colors"
|
||||||
</button>
|
title="Unarchive project"
|
||||||
|
>
|
||||||
|
<ArchiveRestore size={18} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleArchiveProject(project.id, e)}
|
||||||
|
className="text-gray-600 hover:text-yellow-400 transition-colors"
|
||||||
|
title="Archive project"
|
||||||
|
>
|
||||||
|
<Archive size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDeleteProject(project.id, e)}
|
||||||
|
className="text-gray-600 hover:text-red-400 transition-colors"
|
||||||
|
title="Delete project"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{project.description && (
|
{project.description && (
|
||||||
<p className="text-gray-400 text-sm line-clamp-2">{project.description}</p>
|
<p className="text-gray-400 text-sm line-clamp-2">{project.description}</p>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { ArrowLeft, LayoutList, LayoutGrid } from 'lucide-react'
|
import { ArrowLeft, LayoutList, LayoutGrid, Settings } from 'lucide-react'
|
||||||
import { getProject } from '../utils/api'
|
import { getProject } from '../utils/api'
|
||||||
import TreeView from '../components/TreeView'
|
import TreeView from '../components/TreeView'
|
||||||
import KanbanView from '../components/KanbanView'
|
import KanbanView from '../components/KanbanView'
|
||||||
|
import ProjectSettings from '../components/ProjectSettings'
|
||||||
|
|
||||||
function ProjectView() {
|
function ProjectView() {
|
||||||
const { projectId } = useParams()
|
const { projectId } = useParams()
|
||||||
@@ -12,6 +13,7 @@ function ProjectView() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [view, setView] = useState('tree') // 'tree' or 'kanban'
|
const [view, setView] = useState('tree') // 'tree' or 'kanban'
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProject()
|
loadProject()
|
||||||
@@ -65,37 +67,55 @@ function ProjectView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 bg-cyber-darkest rounded-lg p-1 border border-cyber-orange/30">
|
<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')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded transition-colors ${
|
||||||
|
view === 'tree'
|
||||||
|
? 'bg-cyber-orange text-cyber-darkest font-semibold'
|
||||||
|
: 'text-gray-400 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LayoutList size={18} />
|
||||||
|
Tree View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('kanban')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded transition-colors ${
|
||||||
|
view === 'kanban'
|
||||||
|
? 'bg-cyber-orange text-cyber-darkest font-semibold'
|
||||||
|
: 'text-gray-400 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LayoutGrid size={18} />
|
||||||
|
Kanban
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('tree')}
|
onClick={() => setShowSettings(true)}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded transition-colors ${
|
className="p-2 text-gray-400 hover:text-cyber-orange transition-colors border border-cyber-orange/30 rounded-lg hover:border-cyber-orange/60"
|
||||||
view === 'tree'
|
title="Project Settings"
|
||||||
? 'bg-cyber-orange text-cyber-darkest font-semibold'
|
|
||||||
: 'text-gray-400 hover:text-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<LayoutList size={18} />
|
<Settings size={20} />
|
||||||
Tree View
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setView('kanban')}
|
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded transition-colors ${
|
|
||||||
view === 'kanban'
|
|
||||||
? 'bg-cyber-orange text-cyber-darkest font-semibold'
|
|
||||||
: 'text-gray-400 hover:text-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<LayoutGrid size={18} />
|
|
||||||
Kanban
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === 'tree' ? (
|
{view === 'tree' ? (
|
||||||
<TreeView projectId={projectId} />
|
<TreeView projectId={projectId} project={project} />
|
||||||
) : (
|
) : (
|
||||||
<KanbanView projectId={projectId} />
|
<KanbanView projectId={projectId} project={project} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSettings && (
|
||||||
|
<ProjectSettings
|
||||||
|
project={project}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
onUpdate={loadProject}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const API_BASE = '/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}`, {
|
||||||
@@ -22,7 +22,14 @@ async function fetchAPI(endpoint, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
export const getProjects = () => fetchAPI('/projects');
|
export const getProjects = (archived = null) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (archived !== null) {
|
||||||
|
params.append('archived', archived);
|
||||||
|
}
|
||||||
|
const queryString = params.toString();
|
||||||
|
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',
|
||||||
@@ -33,6 +40,8 @@ export const updateProject = (id, data) => fetchAPI(`/projects/${id}`, {
|
|||||||
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 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`);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Format minutes into display string
|
// Format minutes into display string (e.g., "1h 30m" or "45m")
|
||||||
export function formatTime(minutes) {
|
export function formatTime(minutes) {
|
||||||
if (!minutes || minutes === 0) return null;
|
if (!minutes || minutes === 0) return null;
|
||||||
|
|
||||||
@@ -6,8 +6,14 @@ export function formatTime(minutes) {
|
|||||||
return `${minutes}m`;
|
return `${minutes}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hours = minutes / 60;
|
const hours = Math.floor(minutes / 60);
|
||||||
return `${hours.toFixed(1)}h`;
|
const mins = minutes % 60;
|
||||||
|
|
||||||
|
if (mins === 0) {
|
||||||
|
return `${hours}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format tags as comma-separated string
|
// Format tags as comma-separated string
|
||||||
@@ -15,3 +21,66 @@ export function formatTags(tags) {
|
|||||||
if (!tags || tags.length === 0) return null;
|
if (!tags || tags.length === 0) return null;
|
||||||
return tags.join(', ');
|
return tags.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate sum of all LEAF descendant estimates (hierarchical structure)
|
||||||
|
// Excludes tasks marked as "done"
|
||||||
|
export function calculateLeafTime(task) {
|
||||||
|
// If no subtasks, this is a leaf - return its own estimate if not done
|
||||||
|
if (!task.subtasks || task.subtasks.length === 0) {
|
||||||
|
return (task.status !== 'done' && task.estimated_minutes) ? task.estimated_minutes : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has subtasks, so sum up all leaf descendants (excluding done tasks)
|
||||||
|
let total = 0;
|
||||||
|
for (const subtask of task.subtasks) {
|
||||||
|
total += calculateLeafTime(subtask);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate sum of all LEAF descendant estimates (flat task list)
|
||||||
|
// Excludes tasks marked as "done"
|
||||||
|
export function calculateLeafTimeFlat(task, allTasks) {
|
||||||
|
// Find direct children
|
||||||
|
const children = allTasks.filter(t => t.parent_task_id === task.id);
|
||||||
|
|
||||||
|
// If no children, this is a leaf - return its own estimate if not done
|
||||||
|
if (children.length === 0) {
|
||||||
|
return (task.status !== 'done' && task.estimated_minutes) ? task.estimated_minutes : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has children, so sum up all leaf descendants (excluding done tasks)
|
||||||
|
let total = 0;
|
||||||
|
for (const child of children) {
|
||||||
|
total += calculateLeafTimeFlat(child, allTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format time display based on leaf calculation logic
|
||||||
|
export function formatTimeWithTotal(task, allTasks = null) {
|
||||||
|
// Check if task has subtasks
|
||||||
|
const hasSubtasks = allTasks
|
||||||
|
? allTasks.some(t => t.parent_task_id === task.id)
|
||||||
|
: (task.subtasks && task.subtasks.length > 0);
|
||||||
|
|
||||||
|
// Leaf task: use own estimate
|
||||||
|
if (!hasSubtasks) {
|
||||||
|
return formatTime(task.estimated_minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent task: calculate sum of leaf descendants
|
||||||
|
const leafTotal = allTasks
|
||||||
|
? calculateLeafTimeFlat(task, allTasks)
|
||||||
|
: calculateLeafTime(task);
|
||||||
|
|
||||||
|
// If no leaf estimates exist, fall back to own estimate
|
||||||
|
if (leafTotal === 0) {
|
||||||
|
return formatTime(task.estimated_minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show leaf total
|
||||||
|
return formatTime(leafTotal);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [react()],
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
server: {
|
|
||||||
host: '0.0.0.0',
|
return {
|
||||||
port: 5173,
|
plugins: [react()],
|
||||||
proxy: {
|
server: {
|
||||||
'/api': {
|
host: '0.0.0.0',
|
||||||
target: 'http://localhost:8000',
|
port: parseInt(env.VITE_DEV_PORT || '5173'),
|
||||||
changeOrigin: true,
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: env.VITE_API_URL || 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
import-template.json
Normal file
24
import-template.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"project": {
|
||||||
|
"name": "New Project",
|
||||||
|
"description": "Describe the project here."
|
||||||
|
},
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"title": "Root Task Example",
|
||||||
|
"description": "Optional description.",
|
||||||
|
"status": "backlog",
|
||||||
|
"estimated_minutes": 60,
|
||||||
|
"tags": ["example"],
|
||||||
|
"flag_color": "orange",
|
||||||
|
"subtasks": [
|
||||||
|
{
|
||||||
|
"title": "Subtask Example",
|
||||||
|
"status": "backlog",
|
||||||
|
"estimated_minutes": 30,
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user