Files
PROJECT-CONTORL/frontend/src/components/WorkloadUserDetail.tsx
beabigegg 4b5a9c1d0a feat: complete LOW priority code quality improvements
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>
2026-01-07 21:24:36 +08:00

371 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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