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:
beabigegg
2025-12-29 00:31:34 +08:00
parent 1fda7da2c2
commit daca7798e3
41 changed files with 3616 additions and 13 deletions

View File

@@ -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>
}
/>

View 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)',
},
}

View 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',
},
}

View 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',
},
}

View 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',
},
}