feat: implement task management module
Backend (FastAPI): - Database migration for spaces, projects, task_statuses, tasks tables - SQLAlchemy models with relationships - Pydantic schemas for CRUD operations - Spaces API: CRUD with soft delete - Projects API: CRUD with auto-created default statuses - Tasks API: CRUD, status change, assign, subtask support - Permission middleware with Security Level filtering - Subtask depth limit (max 2 levels) Frontend (React + Vite): - Layout component with navigation - Spaces list page - Projects list page - Tasks list page with status management Fixes: - auth_client.py: use 'username' field for external API - config.py: extend JWT expiry to 7 days - auth/router.py: sync Redis session with JWT expiry Tests: 36 passed (unit + integration) E2E: All APIs verified with real authentication OpenSpec: add-task-management archived 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
398
frontend/src/pages/Tasks.tsx
Normal file
398
frontend/src/pages/Tasks.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '../services/api'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
priority: string
|
||||
status_id: string | null
|
||||
status_name: string | null
|
||||
status_color: string | null
|
||||
assignee_id: string | null
|
||||
assignee_name: string | null
|
||||
due_date: string | null
|
||||
subtask_count: number
|
||||
}
|
||||
|
||||
interface TaskStatus {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
is_done: boolean
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
title: string
|
||||
space_id: string
|
||||
}
|
||||
|
||||
export default function Tasks() {
|
||||
const { projectId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [project, setProject] = useState<Project | null>(null)
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [statuses, setStatuses] = useState<TaskStatus[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [newTask, setNewTask] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
})
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [projectId])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [projectRes, tasksRes, statusesRes] = await Promise.all([
|
||||
api.get(`/api/projects/${projectId}`),
|
||||
api.get(`/api/projects/${projectId}/tasks`),
|
||||
api.get(`/api/projects/${projectId}/statuses`),
|
||||
])
|
||||
setProject(projectRes.data)
|
||||
setTasks(tasksRes.data.tasks)
|
||||
setStatuses(statusesRes.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateTask = async () => {
|
||||
if (!newTask.title.trim()) return
|
||||
|
||||
setCreating(true)
|
||||
try {
|
||||
await api.post(`/api/projects/${projectId}/tasks`, newTask)
|
||||
setShowCreateModal(false)
|
||||
setNewTask({ title: '', description: '', priority: 'medium' })
|
||||
loadData()
|
||||
} catch (err) {
|
||||
console.error('Failed to create task:', err)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = async (taskId: string, statusId: string) => {
|
||||
try {
|
||||
await api.patch(`/api/tasks/${taskId}/status`, { status_id: statusId })
|
||||
loadData()
|
||||
} catch (err) {
|
||||
console.error('Failed to update status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const getPriorityStyle = (priority: string): React.CSSProperties => {
|
||||
const colors: { [key: string]: string } = {
|
||||
low: '#808080',
|
||||
medium: '#0066cc',
|
||||
high: '#ff9800',
|
||||
urgent: '#f44336',
|
||||
}
|
||||
return {
|
||||
width: '4px',
|
||||
backgroundColor: colors[priority] || colors.medium,
|
||||
borderRadius: '2px',
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.breadcrumb}>
|
||||
<span onClick={() => navigate('/spaces')} style={styles.breadcrumbLink}>
|
||||
Spaces
|
||||
</span>
|
||||
<span style={styles.breadcrumbSeparator}>/</span>
|
||||
<span
|
||||
onClick={() => navigate(`/spaces/${project?.space_id}`)}
|
||||
style={styles.breadcrumbLink}
|
||||
>
|
||||
Projects
|
||||
</span>
|
||||
<span style={styles.breadcrumbSeparator}>/</span>
|
||||
<span>{project?.title}</span>
|
||||
</div>
|
||||
|
||||
<div style={styles.header}>
|
||||
<h1 style={styles.title}>Tasks</h1>
|
||||
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
|
||||
+ New Task
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={styles.taskList}>
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} style={styles.taskRow}>
|
||||
<div style={getPriorityStyle(task.priority)} />
|
||||
<div style={styles.taskContent}>
|
||||
<div style={styles.taskTitle}>{task.title}</div>
|
||||
<div style={styles.taskMeta}>
|
||||
{task.assignee_name && (
|
||||
<span style={styles.assignee}>{task.assignee_name}</span>
|
||||
)}
|
||||
{task.due_date && (
|
||||
<span style={styles.dueDate}>
|
||||
Due: {new Date(task.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{task.subtask_count > 0 && (
|
||||
<span style={styles.subtaskCount}>
|
||||
{task.subtask_count} subtasks
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={task.status_id || ''}
|
||||
onChange={(e) => handleStatusChange(task.id, e.target.value)}
|
||||
style={{
|
||||
...styles.statusSelect,
|
||||
backgroundColor: task.status_color || '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
{statuses.map((status) => (
|
||||
<option key={status.id} value={status.id}>
|
||||
{status.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<div style={styles.empty}>
|
||||
<p>No tasks yet. Create your first task!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<div style={styles.modalOverlay}>
|
||||
<div style={styles.modal}>
|
||||
<h2 style={styles.modalTitle}>Create New Task</h2>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Task title"
|
||||
value={newTask.title}
|
||||
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
|
||||
style={styles.input}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Description (optional)"
|
||||
value={newTask.description}
|
||||
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
||||
style={styles.textarea}
|
||||
/>
|
||||
<label style={styles.label}>Priority</label>
|
||||
<select
|
||||
value={newTask.priority}
|
||||
onChange={(e) => setNewTask({ ...newTask, priority: e.target.value })}
|
||||
style={styles.select}
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
<div style={styles.modalActions}>
|
||||
<button onClick={() => setShowCreateModal(false)} style={styles.cancelButton}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateTask}
|
||||
disabled={creating || !newTask.title.trim()}
|
||||
style={styles.submitButton}
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: { [key: string]: React.CSSProperties } = {
|
||||
container: {
|
||||
padding: '24px',
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
},
|
||||
breadcrumb: {
|
||||
marginBottom: '16px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
},
|
||||
breadcrumbLink: {
|
||||
color: '#0066cc',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
breadcrumbSeparator: {
|
||||
margin: '0 8px',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
},
|
||||
title: {
|
||||
fontSize: '24px',
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
},
|
||||
createButton: {
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#0066cc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
},
|
||||
taskList: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
taskRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #eee',
|
||||
gap: '12px',
|
||||
},
|
||||
taskContent: {
|
||||
flex: 1,
|
||||
},
|
||||
taskTitle: {
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
},
|
||||
taskMeta: {
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
},
|
||||
assignee: {
|
||||
backgroundColor: '#f0f0f0',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
dueDate: {},
|
||||
subtaskCount: {
|
||||
color: '#999',
|
||||
},
|
||||
statusSelect: {
|
||||
padding: '6px 12px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
color: 'white',
|
||||
},
|
||||
empty: {
|
||||
textAlign: 'center',
|
||||
padding: '48px',
|
||||
color: '#666',
|
||||
},
|
||||
loading: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '200px',
|
||||
},
|
||||
modalOverlay: {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
modal: {
|
||||
backgroundColor: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
width: '400px',
|
||||
maxWidth: '90%',
|
||||
},
|
||||
modalTitle: {
|
||||
marginBottom: '16px',
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
marginBottom: '12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
textarea: {
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
marginBottom: '12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
minHeight: '80px',
|
||||
resize: 'vertical',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
label: {
|
||||
display: 'block',
|
||||
marginBottom: '4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
},
|
||||
select: {
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
marginBottom: '16px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
modalActions: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '12px',
|
||||
},
|
||||
cancelButton: {
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
submitButton: {
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#0066cc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user