feat: complete issue fixes and implement remaining features

## Critical Issues (CRIT-001~003) - All Fixed
- JWT secret key validation with pydantic field_validator
- Login audit logging for success/failure attempts
- Frontend API path prefix removal

## High Priority Issues (HIGH-001~008) - All Fixed
- Project soft delete using is_active flag
- Redis session token bytes handling
- Rate limiting with slowapi (5 req/min for login)
- Attachment API permission checks
- Kanban view with drag-and-drop
- Workload heatmap UI (WorkloadPage, WorkloadHeatmap)
- TaskDetailModal integrating Comments/Attachments
- UserSelect component for task assignment

## Medium Priority Issues (MED-001~012) - All Fixed
- MED-001~005: DB commits, N+1 queries, datetime, error format, blocker flag
- MED-006: Project health dashboard (HealthService, ProjectHealthPage)
- MED-007: Capacity update API (PUT /api/users/{id}/capacity)
- MED-008: Schedule triggers (cron parsing, deadline reminders)
- MED-009: Watermark feature (image/PDF watermarking)
- MED-010~012: useEffect deps, DOM operations, PDF export

## New Files
- backend/app/api/health/ - Project health API
- backend/app/services/health_service.py
- backend/app/services/trigger_scheduler.py
- backend/app/services/watermark_service.py
- backend/app/core/rate_limiter.py
- frontend/src/pages/ProjectHealthPage.tsx
- frontend/src/components/ProjectHealthCard.tsx
- frontend/src/components/KanbanBoard.tsx
- frontend/src/components/WorkloadHeatmap.tsx

## Tests
- 113 new tests passing (health: 32, users: 14, triggers: 35, watermark: 32)

## OpenSpec Archives
- add-project-health-dashboard
- add-capacity-update-api
- add-schedule-triggers
- add-watermark-feature
- add-rate-limiting
- enhance-frontend-ux
- add-resource-management-ui

🤖 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
2026-01-04 21:49:52 +08:00
parent 64874d5425
commit 9b220523ff
90 changed files with 9426 additions and 194 deletions

View File

@@ -1,6 +1,10 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '../services/api'
import { KanbanBoard } from '../components/KanbanBoard'
import { TaskDetailModal } from '../components/TaskDetailModal'
import { UserSelect } from '../components/UserSelect'
import { UserSearchResult } from '../services/collaboration'
interface Task {
id: string
@@ -13,6 +17,7 @@ interface Task {
assignee_id: string | null
assignee_name: string | null
due_date: string | null
time_estimate: number | null
subtask_count: number
}
@@ -29,6 +34,10 @@ interface Project {
space_id: string
}
type ViewMode = 'list' | 'kanban'
const VIEW_MODE_STORAGE_KEY = 'tasks-view-mode'
export default function Tasks() {
const { projectId } = useParams()
const navigate = useNavigate()
@@ -37,23 +46,38 @@ export default function Tasks() {
const [statuses, setStatuses] = useState<TaskStatus[]>([])
const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>(() => {
const saved = localStorage.getItem(VIEW_MODE_STORAGE_KEY)
return (saved === 'kanban' || saved === 'list') ? saved : 'list'
})
const [newTask, setNewTask] = useState({
title: '',
description: '',
priority: 'medium',
assignee_id: '',
due_date: '',
time_estimate: '',
})
const [, setSelectedAssignee] = useState<UserSearchResult | null>(null)
const [creating, setCreating] = useState(false)
const [selectedTask, setSelectedTask] = useState<Task | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
useEffect(() => {
loadData()
}, [projectId])
// Persist view mode
useEffect(() => {
localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode)
}, [viewMode])
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`),
api.get(`/projects/${projectId}`),
api.get(`/projects/${projectId}/tasks`),
api.get(`/projects/${projectId}/statuses`),
])
setProject(projectRes.data)
setTasks(tasksRes.data.tasks)
@@ -70,9 +94,33 @@ export default function Tasks() {
setCreating(true)
try {
await api.post(`/api/projects/${projectId}/tasks`, newTask)
const payload: Record<string, unknown> = {
title: newTask.title,
description: newTask.description || null,
priority: newTask.priority,
}
if (newTask.assignee_id) {
payload.assignee_id = newTask.assignee_id
}
if (newTask.due_date) {
payload.due_date = newTask.due_date
}
if (newTask.time_estimate) {
payload.time_estimate = Number(newTask.time_estimate)
}
await api.post(`/projects/${projectId}/tasks`, payload)
setShowCreateModal(false)
setNewTask({ title: '', description: '', priority: 'medium' })
setNewTask({
title: '',
description: '',
priority: 'medium',
assignee_id: '',
due_date: '',
time_estimate: '',
})
setSelectedAssignee(null)
loadData()
} catch (err) {
console.error('Failed to create task:', err)
@@ -83,13 +131,32 @@ export default function Tasks() {
const handleStatusChange = async (taskId: string, statusId: string) => {
try {
await api.patch(`/api/tasks/${taskId}/status`, { status_id: statusId })
await api.patch(`/tasks/${taskId}/status`, { status_id: statusId })
loadData()
} catch (err) {
console.error('Failed to update status:', err)
}
}
const handleTaskClick = (task: Task) => {
setSelectedTask(task)
setShowDetailModal(true)
}
const handleAssigneeChange = (userId: string | null, user: UserSearchResult | null) => {
setNewTask({ ...newTask, assignee_id: userId || '' })
setSelectedAssignee(user)
}
const handleCloseDetailModal = () => {
setShowDetailModal(false)
setSelectedTask(null)
}
const handleTaskUpdate = () => {
loadData()
}
const getPriorityStyle = (priority: string): React.CSSProperties => {
const colors: { [key: string]: string } = {
low: '#808080',
@@ -127,57 +194,106 @@ export default function Tasks() {
<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)}
<div style={styles.headerActions}>
{/* View Toggle */}
<div style={styles.viewToggle}>
<button
onClick={() => setViewMode('list')}
style={{
...styles.statusSelect,
backgroundColor: task.status_color || '#f5f5f5',
...styles.viewButton,
...(viewMode === 'list' ? styles.viewButtonActive : {}),
}}
aria-label="List view"
>
{statuses.map((status) => (
<option key={status.id} value={status.id}>
{status.name}
</option>
))}
</select>
List
</button>
<button
onClick={() => setViewMode('kanban')}
style={{
...styles.viewButton,
...(viewMode === 'kanban' ? styles.viewButtonActive : {}),
}}
aria-label="Kanban view"
>
Kanban
</button>
</div>
))}
{tasks.length === 0 && (
<div style={styles.empty}>
<p>No tasks yet. Create your first task!</p>
</div>
)}
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
+ New Task
</button>
</div>
</div>
{/* Conditional rendering based on view mode */}
{viewMode === 'list' ? (
<div style={styles.taskList}>
{tasks.map((task) => (
<div
key={task.id}
style={styles.taskRow}
onClick={() => handleTaskClick(task)}
>
<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.time_estimate && (
<span style={styles.timeEstimate}>
Est: {task.time_estimate}h
</span>
)}
{task.subtask_count > 0 && (
<span style={styles.subtaskCount}>
{task.subtask_count} subtasks
</span>
)}
</div>
</div>
<select
value={task.status_id || ''}
onChange={(e) => {
e.stopPropagation()
handleStatusChange(task.id, e.target.value)
}}
onClick={(e) => e.stopPropagation()}
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>
) : (
<KanbanBoard
tasks={tasks}
statuses={statuses}
onStatusChange={handleStatusChange}
onTaskClick={handleTaskClick}
/>
)}
{/* Create Task Modal */}
{showCreateModal && (
<div style={styles.modalOverlay}>
<div style={styles.modal}>
@@ -195,6 +311,7 @@ export default function Tasks() {
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
style={styles.textarea}
/>
<label style={styles.label}>Priority</label>
<select
value={newTask.priority}
@@ -206,6 +323,34 @@ export default function Tasks() {
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
<label style={styles.label}>Assignee</label>
<UserSelect
value={newTask.assignee_id}
onChange={handleAssigneeChange}
placeholder="Select assignee..."
/>
<div style={styles.fieldSpacer} />
<label style={styles.label}>Due Date</label>
<input
type="date"
value={newTask.due_date}
onChange={(e) => setNewTask({ ...newTask, due_date: e.target.value })}
style={styles.input}
/>
<label style={styles.label}>Time Estimate (hours)</label>
<input
type="number"
min="0"
step="0.5"
placeholder="e.g., 2.5"
value={newTask.time_estimate}
onChange={(e) => setNewTask({ ...newTask, time_estimate: e.target.value })}
style={styles.input}
/>
<div style={styles.modalActions}>
<button onClick={() => setShowCreateModal(false)} style={styles.cancelButton}>
Cancel
@@ -221,6 +366,17 @@ export default function Tasks() {
</div>
</div>
)}
{/* Task Detail Modal */}
{selectedTask && (
<TaskDetailModal
task={selectedTask}
statuses={statuses}
isOpen={showDetailModal}
onClose={handleCloseDetailModal}
onUpdate={handleTaskUpdate}
/>
)}
</div>
)
}
@@ -254,6 +410,30 @@ const styles: { [key: string]: React.CSSProperties } = {
fontWeight: 600,
margin: 0,
},
headerActions: {
display: 'flex',
gap: '12px',
alignItems: 'center',
},
viewToggle: {
display: 'flex',
border: '1px solid #ddd',
borderRadius: '6px',
overflow: 'hidden',
},
viewButton: {
padding: '8px 16px',
backgroundColor: 'white',
border: 'none',
cursor: 'pointer',
fontSize: '14px',
color: '#666',
transition: 'background-color 0.2s, color 0.2s',
},
viewButtonActive: {
backgroundColor: '#0066cc',
color: 'white',
},
createButton: {
padding: '10px 20px',
backgroundColor: '#0066cc',
@@ -276,6 +456,8 @@ const styles: { [key: string]: React.CSSProperties } = {
padding: '16px',
borderBottom: '1px solid #eee',
gap: '12px',
cursor: 'pointer',
transition: 'background-color 0.15s ease',
},
taskContent: {
flex: 1,
@@ -297,6 +479,9 @@ const styles: { [key: string]: React.CSSProperties } = {
borderRadius: '4px',
},
dueDate: {},
timeEstimate: {
color: '#0066cc',
},
subtaskCount: {
color: '#999',
},
@@ -329,13 +514,16 @@ const styles: { [key: string]: React.CSSProperties } = {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
},
modal: {
backgroundColor: 'white',
padding: '24px',
borderRadius: '8px',
width: '400px',
width: '450px',
maxWidth: '90%',
maxHeight: '90vh',
overflowY: 'auto',
},
modalTitle: {
marginBottom: '16px',
@@ -369,16 +557,20 @@ const styles: { [key: string]: React.CSSProperties } = {
select: {
width: '100%',
padding: '10px',
marginBottom: '16px',
marginBottom: '12px',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '14px',
boxSizing: 'border-box',
},
fieldSpacer: {
height: '12px',
},
modalActions: {
display: 'flex',
justifyContent: 'flex-end',
gap: '12px',
marginTop: '16px',
},
cancelButton: {
padding: '10px 20px',