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>
371 lines
9.5 KiB
TypeScript
371 lines
9.5 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react'
|
||
import { UserWorkloadDetail, LoadLevel, workloadApi } from '../services/workload'
|
||
import { SkeletonList } from './Skeleton'
|
||
|
||
interface WorkloadUserDetailProps {
|
||
userId: string
|
||
userName: string
|
||
weekStart: string
|
||
isOpen: boolean
|
||
onClose: () => void
|
||
}
|
||
|
||
// Color mapping for load levels
|
||
const loadLevelColors: Record<LoadLevel, string> = {
|
||
normal: '#4caf50',
|
||
warning: '#ff9800',
|
||
overloaded: '#f44336',
|
||
unavailable: '#9e9e9e',
|
||
}
|
||
|
||
const loadLevelLabels: Record<LoadLevel, string> = {
|
||
normal: 'Normal',
|
||
warning: 'Warning',
|
||
overloaded: 'Overloaded',
|
||
unavailable: 'Unavailable',
|
||
}
|
||
|
||
export function WorkloadUserDetail({
|
||
userId,
|
||
userName,
|
||
weekStart,
|
||
isOpen,
|
||
onClose,
|
||
}: WorkloadUserDetailProps) {
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [detail, setDetail] = useState<UserWorkloadDetail | null>(null)
|
||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||
|
||
// A11Y: Handle Escape key to close modal
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape' && isOpen) {
|
||
onClose()
|
||
}
|
||
}
|
||
|
||
if (isOpen) {
|
||
document.addEventListener('keydown', handleKeyDown)
|
||
modalOverlayRef.current?.focus()
|
||
}
|
||
|
||
return () => {
|
||
document.removeEventListener('keydown', handleKeyDown)
|
||
}
|
||
}, [isOpen, onClose])
|
||
|
||
const loadUserDetail = useCallback(async () => {
|
||
setLoading(true)
|
||
setError(null)
|
||
try {
|
||
const data = await workloadApi.getUserWorkload(userId, weekStart)
|
||
setDetail(data)
|
||
} catch (err) {
|
||
console.error('Failed to load user workload:', err)
|
||
setError('Failed to load workload details')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [userId, weekStart])
|
||
|
||
useEffect(() => {
|
||
if (isOpen && userId) {
|
||
loadUserDetail()
|
||
}
|
||
}, [isOpen, userId, weekStart, loadUserDetail])
|
||
|
||
const formatDate = (dateStr: string | null) => {
|
||
if (!dateStr) return '-'
|
||
const date = new Date(dateStr)
|
||
return date.toLocaleDateString('zh-TW', { month: 'short', day: 'numeric' })
|
||
}
|
||
|
||
if (!isOpen) return null
|
||
|
||
return (
|
||
<div
|
||
ref={modalOverlayRef}
|
||
style={styles.overlay}
|
||
onClick={onClose}
|
||
tabIndex={-1}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="workload-detail-title"
|
||
>
|
||
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||
<div style={styles.header}>
|
||
<div>
|
||
<h2 id="workload-detail-title" style={styles.title}>{userName}</h2>
|
||
<span style={styles.subtitle}>Workload Details</span>
|
||
</div>
|
||
<button style={styles.closeButton} onClick={onClose} aria-label="Close">
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div style={{ padding: '16px' }}>
|
||
<SkeletonList count={5} showAvatar={false} />
|
||
</div>
|
||
) : error ? (
|
||
<div style={styles.error}>{error}</div>
|
||
) : detail ? (
|
||
<>
|
||
{/* Summary Section */}
|
||
<div style={styles.summarySection}>
|
||
<div style={styles.summaryCard}>
|
||
<span style={styles.summaryLabel}>Allocated Hours</span>
|
||
<span style={styles.summaryValue}>{detail.summary.allocated_hours}h</span>
|
||
</div>
|
||
<div style={styles.summaryCard}>
|
||
<span style={styles.summaryLabel}>Capacity</span>
|
||
<span style={styles.summaryValue}>{detail.summary.capacity_hours}h</span>
|
||
</div>
|
||
<div style={styles.summaryCard}>
|
||
<span style={styles.summaryLabel}>Load</span>
|
||
<span
|
||
style={{
|
||
...styles.summaryValue,
|
||
color: loadLevelColors[detail.summary.load_level],
|
||
}}
|
||
>
|
||
{detail.summary.load_percentage}%
|
||
</span>
|
||
</div>
|
||
<div style={styles.summaryCard}>
|
||
<span style={styles.summaryLabel}>Status</span>
|
||
<span
|
||
style={{
|
||
...styles.statusBadge,
|
||
backgroundColor: loadLevelColors[detail.summary.load_level],
|
||
}}
|
||
>
|
||
{loadLevelLabels[detail.summary.load_level]}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tasks Section */}
|
||
<div style={styles.tasksSection}>
|
||
<h3 style={styles.sectionTitle}>Tasks This Week</h3>
|
||
{detail.tasks.length === 0 ? (
|
||
<div style={styles.emptyTasks}>No tasks assigned for this week.</div>
|
||
) : (
|
||
<div style={styles.taskList}>
|
||
{detail.tasks.map((task) => (
|
||
<div key={task.task_id} style={styles.taskItem}>
|
||
<div style={styles.taskMain}>
|
||
<span style={styles.taskTitle}>{task.task_title}</span>
|
||
<span style={styles.projectName}>{task.project_name}</span>
|
||
</div>
|
||
<div style={styles.taskMeta}>
|
||
<span style={styles.timeEstimate}>{task.time_estimate}h</span>
|
||
{task.due_date && (
|
||
<span style={styles.dueDate}>Due: {formatDate(task.due_date)}</span>
|
||
)}
|
||
{task.status_name && (
|
||
<span style={styles.status}>{task.status_name}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Total hours breakdown */}
|
||
<div style={styles.totalSection}>
|
||
<span style={styles.totalLabel}>Total Estimated Hours:</span>
|
||
<span style={styles.totalValue}>
|
||
{detail.tasks.reduce((sum, task) => sum + task.time_estimate, 0)}h
|
||
</span>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const styles: { [key: string]: React.CSSProperties } = {
|
||
overlay: {
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
zIndex: 1000,
|
||
},
|
||
modal: {
|
||
backgroundColor: 'white',
|
||
borderRadius: '8px',
|
||
width: '600px',
|
||
maxWidth: '90%',
|
||
maxHeight: '80vh',
|
||
overflow: 'hidden',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
},
|
||
header: {
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'flex-start',
|
||
padding: '20px 24px',
|
||
borderBottom: '1px solid #eee',
|
||
},
|
||
title: {
|
||
fontSize: '20px',
|
||
fontWeight: 600,
|
||
margin: 0,
|
||
color: '#333',
|
||
},
|
||
subtitle: {
|
||
fontSize: '14px',
|
||
color: '#666',
|
||
},
|
||
closeButton: {
|
||
background: 'none',
|
||
border: 'none',
|
||
fontSize: '28px',
|
||
cursor: 'pointer',
|
||
color: '#767676', // WCAG AA compliant
|
||
padding: '0',
|
||
lineHeight: 1,
|
||
},
|
||
loading: {
|
||
padding: '48px',
|
||
textAlign: 'center',
|
||
color: '#666',
|
||
},
|
||
error: {
|
||
padding: '48px',
|
||
textAlign: 'center',
|
||
color: '#f44336',
|
||
},
|
||
summarySection: {
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||
gap: '12px',
|
||
padding: '20px 24px',
|
||
backgroundColor: '#f9f9f9',
|
||
},
|
||
summaryCard: {
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
gap: '4px',
|
||
},
|
||
summaryLabel: {
|
||
fontSize: '12px',
|
||
color: '#666',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.5px',
|
||
},
|
||
summaryValue: {
|
||
fontSize: '20px',
|
||
fontWeight: 600,
|
||
color: '#333',
|
||
},
|
||
statusBadge: {
|
||
display: 'inline-block',
|
||
padding: '4px 10px',
|
||
borderRadius: '4px',
|
||
fontSize: '12px',
|
||
fontWeight: 500,
|
||
color: 'white',
|
||
},
|
||
tasksSection: {
|
||
flex: 1,
|
||
overflowY: 'auto',
|
||
padding: '20px 24px',
|
||
},
|
||
sectionTitle: {
|
||
fontSize: '14px',
|
||
fontWeight: 600,
|
||
color: '#666',
|
||
margin: '0 0 12px 0',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.5px',
|
||
},
|
||
emptyTasks: {
|
||
textAlign: 'center',
|
||
padding: '24px',
|
||
color: '#767676', // WCAG AA compliant
|
||
fontSize: '14px',
|
||
},
|
||
taskList: {
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '8px',
|
||
},
|
||
taskItem: {
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
padding: '12px 16px',
|
||
backgroundColor: '#f9f9f9',
|
||
borderRadius: '6px',
|
||
borderLeft: '3px solid #0066cc',
|
||
},
|
||
taskMain: {
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '2px',
|
||
},
|
||
taskTitle: {
|
||
fontSize: '14px',
|
||
fontWeight: 500,
|
||
color: '#333',
|
||
},
|
||
projectName: {
|
||
fontSize: '12px',
|
||
color: '#666',
|
||
},
|
||
taskMeta: {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '12px',
|
||
},
|
||
timeEstimate: {
|
||
fontSize: '14px',
|
||
fontWeight: 600,
|
||
color: '#0066cc',
|
||
},
|
||
dueDate: {
|
||
fontSize: '12px',
|
||
color: '#666',
|
||
},
|
||
status: {
|
||
fontSize: '11px',
|
||
padding: '2px 8px',
|
||
backgroundColor: '#e0e0e0',
|
||
borderRadius: '4px',
|
||
color: '#666',
|
||
},
|
||
totalSection: {
|
||
display: 'flex',
|
||
justifyContent: 'flex-end',
|
||
alignItems: 'center',
|
||
gap: '8px',
|
||
padding: '16px 24px',
|
||
borderTop: '1px solid #eee',
|
||
backgroundColor: '#f9f9f9',
|
||
},
|
||
totalLabel: {
|
||
fontSize: '14px',
|
||
color: '#666',
|
||
},
|
||
totalValue: {
|
||
fontSize: '18px',
|
||
fontWeight: 600,
|
||
color: '#333',
|
||
},
|
||
}
|
||
|
||
export default WorkloadUserDetail
|