Backend: - LOW-002: Add Query validation with max page size limits (100) - LOW-003: Replace magic strings with TaskStatus.is_done flag - LOW-004: Add 'creation' trigger type validation - Add action_executor.py with UpdateFieldAction and AutoAssignAction Frontend: - LOW-005: Replace TypeScript 'any' with 'unknown' + type guards - LOW-006: Add ConfirmModal component with A11Y support - LOW-007: Add ToastContext for user feedback notifications - LOW-009: Add Skeleton components (17 loading states replaced) - LOW-010: Setup Vitest with 21 tests for ConfirmModal and Skeleton Components updated: - App.tsx, ProtectedRoute.tsx, Spaces.tsx, Projects.tsx, Tasks.tsx - ProjectSettings.tsx, AuditPage.tsx, WorkloadPage.tsx, ProjectHealthPage.tsx - Comments.tsx, AttachmentList.tsx, TriggerList.tsx, TaskDetailModal.tsx - NotificationBell.tsx, BlockerDialog.tsx, CalendarView.tsx, WorkloadUserDetail.tsx 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
315 lines
8.2 KiB
TypeScript
315 lines
8.2 KiB
TypeScript
import { useState } from 'react'
|
|
import { CustomValueResponse } from '../services/customFields'
|
|
|
|
interface Task {
|
|
id: string
|
|
project_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
|
|
start_date: string | null
|
|
time_estimate: number | null
|
|
subtask_count: number
|
|
custom_values?: CustomValueResponse[]
|
|
}
|
|
|
|
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>
|
|
)}
|
|
{/* Display custom field values (limit to first 2 for compact display) */}
|
|
{task.custom_values?.slice(0, 2).map((cv) => (
|
|
<span key={cv.field_id} style={styles.customValueBadge}>
|
|
{cv.field_name}: {cv.display_value || cv.value || '-'}
|
|
</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: '#767676', // WCAG AA compliant
|
|
},
|
|
customValueBadge: {
|
|
backgroundColor: '#f3e5f5',
|
|
color: '#7b1fa2',
|
|
padding: '2px 6px',
|
|
borderRadius: '4px',
|
|
fontSize: '10px',
|
|
maxWidth: '100px',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
},
|
|
emptyColumn: {
|
|
textAlign: 'center',
|
|
padding: '24px',
|
|
color: '#767676', // WCAG AA compliant
|
|
fontSize: '13px',
|
|
border: '2px dashed #ddd',
|
|
borderRadius: '6px',
|
|
},
|
|
}
|
|
|
|
export default KanbanBoard
|