Main branch Resync on w/ gitea. v0.1.6 #1
25
CHANGELOG.md
25
CHANGELOG.md
@@ -5,6 +5,31 @@ All notable changes to TESSERACT will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.1.5] - 2025-01-XX
|
||||||
|
|
||||||
|
### 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
|
## [0.1.4] - 2025-01-XX
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Task Decomposition Engine** - A self-hosted web application for managing deeply nested todo trees with advanced time tracking and project planning capabilities.
|
**Task Decomposition Engine** - A self-hosted web application for managing deeply nested todo trees with advanced time tracking and project planning capabilities.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
@@ -12,7 +12,9 @@ TESSERACT is designed for complex project management where tasks naturally decom
|
|||||||
### Key Features
|
### Key Features
|
||||||
|
|
||||||
- **Arbitrary-Depth Nesting**: Create tasks within tasks within tasks - no limits on hierarchy depth
|
- **Arbitrary-Depth Nesting**: Create tasks within tasks within tasks - no limits on hierarchy depth
|
||||||
- **Dual View Modes**: Toggle between Tree View (hierarchical) and Kanban Board (status-based)
|
- **Dual View Modes**:
|
||||||
|
- **Tree View**: Hierarchical collapsible tree with full nesting
|
||||||
|
- **Nested Kanban**: Status-based board where parent tasks appear in multiple columns
|
||||||
- **Intelligent Time Tracking**:
|
- **Intelligent Time Tracking**:
|
||||||
- Leaf-based time calculation (parent times = sum of descendant leaf tasks)
|
- Leaf-based time calculation (parent times = sum of descendant leaf tasks)
|
||||||
- Automatic exclusion of completed tasks from time estimates
|
- Automatic exclusion of completed tasks from time estimates
|
||||||
@@ -101,13 +103,29 @@ docker-compose down -v
|
|||||||
|
|
||||||
**Expand/Collapse**: Click chevron icon to show/hide subtasks
|
**Expand/Collapse**: Click chevron icon to show/hide subtasks
|
||||||
|
|
||||||
#### Kanban View
|
#### Kanban View (Nested)
|
||||||
|
|
||||||
**Add Task**: Click "+" in any column to create a task with that status
|
The Kanban board displays tasks in a nested hierarchy while maintaining status-based columns:
|
||||||
|
|
||||||
**Move Tasks**: Drag and drop cards between columns to change status
|
**Parent Cards**:
|
||||||
|
- Appear in **every column** where they have subtasks
|
||||||
|
- Display "X of Y subtasks in this column" counter
|
||||||
|
- Have distinct styling (thick orange border, bold title)
|
||||||
|
- Click chevron to expand/collapse and see children in that column
|
||||||
|
- Cannot be dragged (only leaf tasks are draggable)
|
||||||
|
|
||||||
**Edit Tasks**: Use the three-dot menu same as Tree View
|
**Leaf Tasks**:
|
||||||
|
- Appear only in their status column
|
||||||
|
- Fully draggable between columns
|
||||||
|
- Standard card styling
|
||||||
|
|
||||||
|
**Add Task**: Click "+" in any column to create a root-level task
|
||||||
|
|
||||||
|
**Move Tasks**: Drag and drop leaf task cards between columns
|
||||||
|
|
||||||
|
**Edit Tasks**: Use the three-dot menu on any card (parent or leaf)
|
||||||
|
|
||||||
|
**Example**: A project with backend (2 tasks in backlog, 1 in progress) and frontend (1 in done) will show the project card in all three columns with appropriate counts.
|
||||||
|
|
||||||
### Understanding Time Estimates
|
### Understanding Time Estimates
|
||||||
|
|
||||||
@@ -354,7 +372,6 @@ Ensure JSON structure matches schema. Common issues:
|
|||||||
See [CHANGELOG.md](CHANGELOG.md) for version history.
|
See [CHANGELOG.md](CHANGELOG.md) for version history.
|
||||||
|
|
||||||
### v0.2.0 (Planned)
|
### v0.2.0 (Planned)
|
||||||
- Nested Kanban view (parent cards split across columns)
|
|
||||||
- Task dependencies and blocking relationships
|
- Task dependencies and blocking relationships
|
||||||
- Due dates and calendar view
|
- Due dates and calendar view
|
||||||
- Progress tracking (% complete)
|
- Progress tracking (% complete)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function App() {
|
|||||||
<h1 className="text-2xl font-bold text-cyber-orange">
|
<h1 className="text-2xl font-bold text-cyber-orange">
|
||||||
TESSERACT
|
TESSERACT
|
||||||
<span className="ml-3 text-sm text-gray-500">Task Decomposition Engine</span>
|
<span className="ml-3 text-sm text-gray-500">Task Decomposition Engine</span>
|
||||||
<span className="ml-2 text-xs text-gray-600">v0.1.4</span>
|
<span className="ml-2 text-xs text-gray-600">v0.1.5</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Plus, Check, X, Flag, Clock } from 'lucide-react'
|
import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
getProjectTasks,
|
getProjectTasks,
|
||||||
createTask,
|
createTask,
|
||||||
@@ -27,14 +27,51 @@ const FLAG_COLORS = {
|
|||||||
pink: 'bg-pink-500'
|
pink: 'bg-pink-500'
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
// 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 }) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [editTitle, setEditTitle] = useState(task.title)
|
const [editTitle, setEditTitle] = useState(task.title)
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -56,100 +93,152 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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="flex items-center gap-2 text-sm">
|
</div>
|
||||||
{/* Flag indicator */}
|
) : (
|
||||||
{task.flag_color && FLAG_COLORS[task.flag_color] && (
|
<>
|
||||||
<Flag size={12} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" />
|
<div className="flex justify-between items-start">
|
||||||
)}
|
<div className="flex-1">
|
||||||
<span className="text-gray-200">{task.title}</span>
|
<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>
|
||||||
|
|
||||||
{/* Parent task context */}
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
{parentTask && (
|
<TaskMenu
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
task={task}
|
||||||
↳ subtask of: <span className="text-cyber-orange">{parentTask.title}</span>
|
onUpdate={onUpdate}
|
||||||
</div>
|
onDelete={handleDelete}
|
||||||
)}
|
onEdit={() => setIsEditing(true)}
|
||||||
|
/>
|
||||||
{/* Metadata row */}
|
</div>
|
||||||
{(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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
{/* Expanded children */}
|
||||||
<TaskMenu
|
{isParent && isExpanded && childrenInColumn.length > 0 && (
|
||||||
task={task}
|
<div className="ml-6 mt-2 space-y-2">
|
||||||
onUpdate={onUpdate}
|
{childrenInColumn.map(child => (
|
||||||
onDelete={handleDelete}
|
<TaskCard
|
||||||
onEdit={() => setIsEditing(true)}
|
key={child.id}
|
||||||
/>
|
task={child}
|
||||||
</div>
|
allTasks={allTasks}
|
||||||
</div>
|
onUpdate={onUpdate}
|
||||||
</>
|
onDragStart={onDragStart}
|
||||||
|
isParent={false}
|
||||||
|
columnStatus={columnStatus}
|
||||||
|
expandedCards={expandedCards}
|
||||||
|
setExpandedCards={setExpandedCards}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, onDragOver }) {
|
function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards }) {
|
||||||
const [showAddTask, setShowAddTask] = useState(false)
|
const [showAddTask, setShowAddTask] = useState(false)
|
||||||
|
|
||||||
const handleAddTask = async (taskData) => {
|
const handleAddTask = async (taskData) => {
|
||||||
@@ -158,6 +247,7 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
|
|||||||
project_id: parseInt(projectId),
|
project_id: parseInt(projectId),
|
||||||
parent_task_id: null,
|
parent_task_id: null,
|
||||||
title: taskData.title,
|
title: taskData.title,
|
||||||
|
description: taskData.description,
|
||||||
status: status.key,
|
status: status.key,
|
||||||
tags: taskData.tags,
|
tags: taskData.tags,
|
||||||
estimated_minutes: taskData.estimated_minutes,
|
estimated_minutes: taskData.estimated_minutes,
|
||||||
@@ -170,16 +260,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)}
|
||||||
@@ -200,17 +311,25 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -220,6 +339,7 @@ function KanbanView({ projectId }) {
|
|||||||
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({})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTasks()
|
loadTasks()
|
||||||
@@ -237,6 +357,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()
|
||||||
}
|
}
|
||||||
@@ -244,11 +377,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}`)
|
||||||
@@ -265,19 +409,38 @@ 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 => (
|
{STATUSES.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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const FLAG_COLORS = [
|
|||||||
|
|
||||||
function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
|
function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
const [tags, setTags] = useState('')
|
const [tags, setTags] = useState('')
|
||||||
const [hours, setHours] = useState('')
|
const [hours, setHours] = useState('')
|
||||||
const [minutes, setMinutes] = useState('')
|
const [minutes, setMinutes] = useState('')
|
||||||
@@ -33,6 +34,7 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
|
|||||||
|
|
||||||
const taskData = {
|
const taskData = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
tags: tagList && tagList.length > 0 ? tagList : null,
|
tags: tagList && tagList.length > 0 ? tagList : null,
|
||||||
estimated_minutes: totalMinutes > 0 ? totalMinutes : null,
|
estimated_minutes: totalMinutes > 0 ? totalMinutes : null,
|
||||||
flag_color: flagColor
|
flag_color: flagColor
|
||||||
@@ -56,6 +58,18 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Tags */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1">Tags (comma-separated)</label>
|
<label className="block text-xs text-gray-400 mb-1">Tags (comma-separated)</label>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo } from 'lucide-react'
|
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText } from 'lucide-react'
|
||||||
import { updateTask } from '../utils/api'
|
import { updateTask } from '../utils/api'
|
||||||
|
|
||||||
const FLAG_COLORS = [
|
const FLAG_COLORS = [
|
||||||
@@ -22,6 +22,7 @@ const STATUSES = [
|
|||||||
function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
|
function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [showTimeEdit, setShowTimeEdit] = useState(false)
|
const [showTimeEdit, setShowTimeEdit] = useState(false)
|
||||||
|
const [showDescriptionEdit, setShowDescriptionEdit] = useState(false)
|
||||||
const [showTagsEdit, setShowTagsEdit] = useState(false)
|
const [showTagsEdit, setShowTagsEdit] = useState(false)
|
||||||
const [showFlagEdit, setShowFlagEdit] = useState(false)
|
const [showFlagEdit, setShowFlagEdit] = useState(false)
|
||||||
const [showStatusEdit, setShowStatusEdit] = useState(false)
|
const [showStatusEdit, setShowStatusEdit] = useState(false)
|
||||||
@@ -32,6 +33,7 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
|
|||||||
|
|
||||||
const [editHours, setEditHours] = useState(initialHours)
|
const [editHours, setEditHours] = useState(initialHours)
|
||||||
const [editMinutes, setEditMinutes] = useState(initialMinutes)
|
const [editMinutes, setEditMinutes] = useState(initialMinutes)
|
||||||
|
const [editDescription, setEditDescription] = useState(task.description || '')
|
||||||
const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '')
|
const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '')
|
||||||
const menuRef = useRef(null)
|
const menuRef = useRef(null)
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
|
|||||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
setShowTimeEdit(false)
|
setShowTimeEdit(false)
|
||||||
|
setShowDescriptionEdit(false)
|
||||||
setShowTagsEdit(false)
|
setShowTagsEdit(false)
|
||||||
setShowFlagEdit(false)
|
setShowFlagEdit(false)
|
||||||
setShowStatusEdit(false)
|
setShowStatusEdit(false)
|
||||||
@@ -65,6 +68,18 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 () => {
|
const handleUpdateTags = async () => {
|
||||||
try {
|
try {
|
||||||
const tags = editTags
|
const tags = editTags
|
||||||
@@ -184,6 +199,52 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
|
|||||||
</button>
|
</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 */}
|
{/* Tags Edit */}
|
||||||
{showTagsEdit ? (
|
{showTagsEdit ? (
|
||||||
<div className="p-3 border-b border-cyber-orange/20">
|
<div className="p-3 border-b border-cyber-orange/20">
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
project_id: parseInt(projectId),
|
project_id: parseInt(projectId),
|
||||||
parent_task_id: task.id,
|
parent_task_id: task.id,
|
||||||
title: taskData.title,
|
title: taskData.title,
|
||||||
|
description: taskData.description,
|
||||||
status: 'backlog',
|
status: 'backlog',
|
||||||
tags: taskData.tags,
|
tags: taskData.tags,
|
||||||
estimated_minutes: taskData.estimated_minutes,
|
estimated_minutes: taskData.estimated_minutes,
|
||||||
@@ -187,6 +188,13 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{task.description && (
|
||||||
|
<div className="mt-2 text-xs text-gray-400 italic">
|
||||||
|
{task.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -266,6 +274,7 @@ function TreeView({ projectId }) {
|
|||||||
project_id: parseInt(projectId),
|
project_id: parseInt(projectId),
|
||||||
parent_task_id: null,
|
parent_task_id: null,
|
||||||
title: taskData.title,
|
title: taskData.title,
|
||||||
|
description: taskData.description,
|
||||||
status: 'backlog',
|
status: 'backlog',
|
||||||
tags: taskData.tags,
|
tags: taskData.tags,
|
||||||
estimated_minutes: taskData.estimated_minutes,
|
estimated_minutes: taskData.estimated_minutes,
|
||||||
|
|||||||
Reference in New Issue
Block a user