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:
@@ -2,7 +2,11 @@ import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from './contexts/AuthContext'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Spaces from './pages/Spaces'
|
||||
import Projects from './pages/Projects'
|
||||
import Tasks from './pages/Tasks'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
@@ -21,7 +25,39 @@ function App() {
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/spaces"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Spaces />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/spaces/:spaceId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Projects />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/:projectId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Tasks />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
131
frontend/src/components/Layout.tsx
Normal file
131
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Dashboard' },
|
||||
{ path: '/spaces', label: 'Spaces' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<header style={styles.header}>
|
||||
<div style={styles.headerLeft}>
|
||||
<h1 style={styles.logo} onClick={() => navigate('/')}>
|
||||
Project Control
|
||||
</h1>
|
||||
<nav style={styles.nav}>
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
style={{
|
||||
...styles.navItem,
|
||||
...(location.pathname === item.path ? styles.navItemActive : {}),
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div style={styles.headerRight}>
|
||||
<span style={styles.userName}>{user?.name}</span>
|
||||
{user?.is_system_admin && (
|
||||
<span style={styles.badge}>Admin</span>
|
||||
)}
|
||||
<button onClick={handleLogout} style={styles.logoutButton}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main style={styles.main}>{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: { [key: string]: React.CSSProperties } = {
|
||||
container: {
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 24px',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
headerLeft: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '24px',
|
||||
},
|
||||
logo: {
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
margin: 0,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
nav: {
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
},
|
||||
navItem: {
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
},
|
||||
navItemActive: {
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#0066cc',
|
||||
fontWeight: 500,
|
||||
},
|
||||
headerRight: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
},
|
||||
userName: {
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
},
|
||||
badge: {
|
||||
backgroundColor: '#0066cc',
|
||||
color: 'white',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
},
|
||||
logoutButton: {
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
},
|
||||
main: {
|
||||
minHeight: 'calc(100vh - 60px)',
|
||||
},
|
||||
}
|
||||
351
frontend/src/pages/Projects.tsx
Normal file
351
frontend/src/pages/Projects.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '../services/api'
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
space_id: string
|
||||
title: string
|
||||
description: string | null
|
||||
owner_id: string
|
||||
owner_name: string | null
|
||||
security_level: string
|
||||
status: string
|
||||
task_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface Space {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function Projects() {
|
||||
const { spaceId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [space, setSpace] = useState<Space | null>(null)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [newProject, setNewProject] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
security_level: 'department',
|
||||
})
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [spaceId])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [spaceRes, projectsRes] = await Promise.all([
|
||||
api.get(`/api/spaces/${spaceId}`),
|
||||
api.get(`/api/spaces/${spaceId}/projects`),
|
||||
])
|
||||
setSpace(spaceRes.data)
|
||||
setProjects(projectsRes.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (!newProject.title.trim()) return
|
||||
|
||||
setCreating(true)
|
||||
try {
|
||||
await api.post(`/api/spaces/${spaceId}/projects`, newProject)
|
||||
setShowCreateModal(false)
|
||||
setNewProject({ title: '', description: '', security_level: 'department' })
|
||||
loadData()
|
||||
} catch (err) {
|
||||
console.error('Failed to create project:', err)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getSecurityBadgeStyle = (level: string): React.CSSProperties => {
|
||||
const colors: { [key: string]: { bg: string; text: string } } = {
|
||||
public: { bg: '#e8f5e9', text: '#2e7d32' },
|
||||
department: { bg: '#e3f2fd', text: '#1565c0' },
|
||||
confidential: { bg: '#fce4ec', text: '#c62828' },
|
||||
}
|
||||
const color = colors[level] || colors.department
|
||||
return {
|
||||
...styles.badge,
|
||||
backgroundColor: color.bg,
|
||||
color: color.text,
|
||||
}
|
||||
}
|
||||
|
||||
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>{space?.name}</span>
|
||||
</div>
|
||||
|
||||
<div style={styles.header}>
|
||||
<h1 style={styles.title}>Projects</h1>
|
||||
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
|
||||
+ New Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={styles.grid}>
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
style={styles.card}
|
||||
onClick={() => navigate(`/projects/${project.id}`)}
|
||||
>
|
||||
<div style={styles.cardHeader}>
|
||||
<h3 style={styles.cardTitle}>{project.title}</h3>
|
||||
<span style={getSecurityBadgeStyle(project.security_level)}>
|
||||
{project.security_level}
|
||||
</span>
|
||||
</div>
|
||||
<p style={styles.cardDescription}>
|
||||
{project.description || 'No description'}
|
||||
</p>
|
||||
<div style={styles.cardMeta}>
|
||||
<span>{project.task_count} tasks</span>
|
||||
<span>Owner: {project.owner_name || 'Unknown'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{projects.length === 0 && (
|
||||
<div style={styles.empty}>
|
||||
<p>No projects yet. Create your first project!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<div style={styles.modalOverlay}>
|
||||
<div style={styles.modal}>
|
||||
<h2 style={styles.modalTitle}>Create New Project</h2>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Project title"
|
||||
value={newProject.title}
|
||||
onChange={(e) => setNewProject({ ...newProject, title: e.target.value })}
|
||||
style={styles.input}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Description (optional)"
|
||||
value={newProject.description}
|
||||
onChange={(e) => setNewProject({ ...newProject, description: e.target.value })}
|
||||
style={styles.textarea}
|
||||
/>
|
||||
<label style={styles.label}>Security Level</label>
|
||||
<select
|
||||
value={newProject.security_level}
|
||||
onChange={(e) => setNewProject({ ...newProject, security_level: e.target.value })}
|
||||
style={styles.select}
|
||||
>
|
||||
<option value="public">Public - All users</option>
|
||||
<option value="department">Department - Same department only</option>
|
||||
<option value="confidential">Confidential - Owner only</option>
|
||||
</select>
|
||||
<div style={styles.modalActions}>
|
||||
<button onClick={() => setShowCreateModal(false)} style={styles.cancelButton}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateProject}
|
||||
disabled={creating || !newProject.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,
|
||||
},
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||
gap: '16px',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: 'white',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
cardHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '8px',
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
},
|
||||
badge: {
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
cardDescription: {
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
cardMeta: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
},
|
||||
empty: {
|
||||
gridColumn: '1 / -1',
|
||||
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',
|
||||
},
|
||||
}
|
||||
257
frontend/src/pages/Spaces.tsx
Normal file
257
frontend/src/pages/Spaces.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../services/api'
|
||||
|
||||
interface Space {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
owner_id: string
|
||||
owner_name: string | null
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default function Spaces() {
|
||||
const navigate = useNavigate()
|
||||
const [spaces, setSpaces] = useState<Space[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [newSpace, setNewSpace] = useState({ name: '', description: '' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadSpaces()
|
||||
}, [])
|
||||
|
||||
const loadSpaces = async () => {
|
||||
try {
|
||||
const response = await api.get('/api/spaces')
|
||||
setSpaces(response.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load spaces:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateSpace = async () => {
|
||||
if (!newSpace.name.trim()) return
|
||||
|
||||
setCreating(true)
|
||||
try {
|
||||
await api.post('/api/spaces', newSpace)
|
||||
setShowCreateModal(false)
|
||||
setNewSpace({ name: '', description: '' })
|
||||
loadSpaces()
|
||||
} catch (err) {
|
||||
console.error('Failed to create space:', err)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<h1 style={styles.title}>Spaces</h1>
|
||||
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
|
||||
+ New Space
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={styles.grid}>
|
||||
{spaces.map((space) => (
|
||||
<div
|
||||
key={space.id}
|
||||
style={styles.card}
|
||||
onClick={() => navigate(`/spaces/${space.id}`)}
|
||||
>
|
||||
<h3 style={styles.cardTitle}>{space.name}</h3>
|
||||
<p style={styles.cardDescription}>
|
||||
{space.description || 'No description'}
|
||||
</p>
|
||||
<div style={styles.cardMeta}>
|
||||
<span>Owner: {space.owner_name || 'Unknown'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{spaces.length === 0 && (
|
||||
<div style={styles.empty}>
|
||||
<p>No spaces yet. Create your first space to get started!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<div style={styles.modalOverlay}>
|
||||
<div style={styles.modal}>
|
||||
<h2 style={styles.modalTitle}>Create New Space</h2>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Space name"
|
||||
value={newSpace.name}
|
||||
onChange={(e) => setNewSpace({ ...newSpace, name: e.target.value })}
|
||||
style={styles.input}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Description (optional)"
|
||||
value={newSpace.description}
|
||||
onChange={(e) => setNewSpace({ ...newSpace, description: e.target.value })}
|
||||
style={styles.textarea}
|
||||
/>
|
||||
<div style={styles.modalActions}>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateSpace}
|
||||
disabled={creating || !newSpace.name.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',
|
||||
},
|
||||
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,
|
||||
},
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||
gap: '16px',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: 'white',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
cursor: 'pointer',
|
||||
transition: 'box-shadow 0.2s',
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
marginBottom: '8px',
|
||||
},
|
||||
cardDescription: {
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
cardMeta: {
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
},
|
||||
empty: {
|
||||
gridColumn: '1 / -1',
|
||||
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: '16px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
minHeight: '80px',
|
||||
resize: 'vertical',
|
||||
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',
|
||||
},
|
||||
}
|
||||
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