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:
293
frontend/src/components/KanbanBoard.tsx
Normal file
293
frontend/src/components/KanbanBoard.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
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
|
||||
time_estimate: number | null
|
||||
subtask_count: number
|
||||
}
|
||||
|
||||
interface TaskStatus {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
is_done: boolean
|
||||
}
|
||||
|
||||
interface KanbanBoardProps {
|
||||
tasks: Task[]
|
||||
statuses: TaskStatus[]
|
||||
onStatusChange: (taskId: string, statusId: string) => void
|
||||
onTaskClick: (task: Task) => void
|
||||
}
|
||||
|
||||
export function KanbanBoard({
|
||||
tasks,
|
||||
statuses,
|
||||
onStatusChange,
|
||||
onTaskClick,
|
||||
}: KanbanBoardProps) {
|
||||
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null)
|
||||
const [dragOverColumnId, setDragOverColumnId] = useState<string | null>(null)
|
||||
|
||||
// Group tasks by status
|
||||
const tasksByStatus: Record<string, Task[]> = {}
|
||||
statuses.forEach((status) => {
|
||||
tasksByStatus[status.id] = tasks.filter((task) => task.status_id === status.id)
|
||||
})
|
||||
// Tasks without status
|
||||
const unassignedTasks = tasks.filter((task) => !task.status_id)
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, taskId: string) => {
|
||||
setDraggedTaskId(taskId)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', taskId)
|
||||
// Add a slight delay to allow the drag image to be captured
|
||||
const target = e.target as HTMLElement
|
||||
setTimeout(() => {
|
||||
target.style.opacity = '0.5'
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const handleDragEnd = (e: React.DragEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
target.style.opacity = '1'
|
||||
setDraggedTaskId(null)
|
||||
setDragOverColumnId(null)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, statusId: string) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
if (dragOverColumnId !== statusId) {
|
||||
setDragOverColumnId(statusId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOverColumnId(null)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent, statusId: string) => {
|
||||
e.preventDefault()
|
||||
const taskId = e.dataTransfer.getData('text/plain')
|
||||
if (taskId && draggedTaskId) {
|
||||
const task = tasks.find((t) => t.id === taskId)
|
||||
if (task && task.status_id !== statusId) {
|
||||
onStatusChange(taskId, statusId)
|
||||
}
|
||||
}
|
||||
setDraggedTaskId(null)
|
||||
setDragOverColumnId(null)
|
||||
}
|
||||
|
||||
const getPriorityColor = (priority: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
low: '#808080',
|
||||
medium: '#0066cc',
|
||||
high: '#ff9800',
|
||||
urgent: '#f44336',
|
||||
}
|
||||
return colors[priority] || colors.medium
|
||||
}
|
||||
|
||||
const renderTaskCard = (task: Task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
style={{
|
||||
...styles.taskCard,
|
||||
borderLeftColor: getPriorityColor(task.priority),
|
||||
opacity: draggedTaskId === task.id ? 0.5 : 1,
|
||||
}}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, task.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => onTaskClick(task)}
|
||||
>
|
||||
<div style={styles.taskTitle}>{task.title}</div>
|
||||
{task.description && (
|
||||
<div style={styles.taskDescription}>
|
||||
{task.description.length > 80
|
||||
? task.description.substring(0, 80) + '...'
|
||||
: task.description}
|
||||
</div>
|
||||
)}
|
||||
<div style={styles.taskMeta}>
|
||||
{task.assignee_name && (
|
||||
<span style={styles.assigneeBadge}>{task.assignee_name}</span>
|
||||
)}
|
||||
{task.due_date && (
|
||||
<span style={styles.dueDate}>
|
||||
{new Date(task.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{task.subtask_count > 0 && (
|
||||
<span style={styles.subtaskBadge}>{task.subtask_count} subtasks</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={styles.board}>
|
||||
{/* Unassigned column (if there are tasks without status) */}
|
||||
{unassignedTasks.length > 0 && (
|
||||
<div style={styles.column}>
|
||||
<div
|
||||
style={{
|
||||
...styles.columnHeader,
|
||||
backgroundColor: '#9e9e9e',
|
||||
}}
|
||||
>
|
||||
<span style={styles.columnTitle}>No Status</span>
|
||||
<span style={styles.taskCount}>{unassignedTasks.length}</span>
|
||||
</div>
|
||||
<div style={styles.taskList}>
|
||||
{unassignedTasks.map(renderTaskCard)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status columns */}
|
||||
{statuses.map((status) => (
|
||||
<div
|
||||
key={status.id}
|
||||
style={{
|
||||
...styles.column,
|
||||
...(dragOverColumnId === status.id ? styles.columnDragOver : {}),
|
||||
}}
|
||||
onDragOver={(e) => handleDragOver(e, status.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, status.id)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...styles.columnHeader,
|
||||
backgroundColor: status.color || '#e0e0e0',
|
||||
}}
|
||||
>
|
||||
<span style={styles.columnTitle}>{status.name}</span>
|
||||
<span style={styles.taskCount}>
|
||||
{tasksByStatus[status.id]?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.taskList}>
|
||||
{tasksByStatus[status.id]?.map(renderTaskCard)}
|
||||
{(!tasksByStatus[status.id] || tasksByStatus[status.id].length === 0) && (
|
||||
<div style={styles.emptyColumn}>
|
||||
Drop tasks here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
board: {
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
overflowX: 'auto',
|
||||
paddingBottom: '16px',
|
||||
minHeight: '500px',
|
||||
},
|
||||
column: {
|
||||
flex: '0 0 280px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: 'calc(100vh - 200px)',
|
||||
transition: 'background-color 0.2s ease',
|
||||
},
|
||||
columnDragOver: {
|
||||
backgroundColor: '#e3f2fd',
|
||||
boxShadow: 'inset 0 0 0 2px #0066cc',
|
||||
},
|
||||
columnHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
},
|
||||
columnTitle: {
|
||||
fontSize: '14px',
|
||||
},
|
||||
taskCount: {
|
||||
fontSize: '12px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '10px',
|
||||
},
|
||||
taskList: {
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
},
|
||||
taskCard: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
cursor: 'grab',
|
||||
borderLeft: '4px solid',
|
||||
transition: 'box-shadow 0.2s ease, transform 0.2s ease',
|
||||
},
|
||||
taskTitle: {
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '6px',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
taskDescription: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
marginBottom: '8px',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
taskMeta: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6px',
|
||||
fontSize: '11px',
|
||||
},
|
||||
assigneeBadge: {
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1565c0',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
dueDate: {
|
||||
color: '#666',
|
||||
},
|
||||
subtaskBadge: {
|
||||
color: '#999',
|
||||
},
|
||||
emptyColumn: {
|
||||
textAlign: 'center',
|
||||
padding: '24px',
|
||||
color: '#999',
|
||||
fontSize: '13px',
|
||||
border: '2px dashed #ddd',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
}
|
||||
|
||||
export default KanbanBoard
|
||||
Reference in New Issue
Block a user