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:
340
frontend/src/components/WorkloadUserDetail.tsx
Normal file
340
frontend/src/components/WorkloadUserDetail.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { UserWorkloadDetail, LoadLevel, workloadApi } from '../services/workload'
|
||||
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && userId) {
|
||||
loadUserDetail()
|
||||
}
|
||||
}, [isOpen, userId, weekStart])
|
||||
|
||||
const loadUserDetail = 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)
|
||||
}
|
||||
}
|
||||
|
||||
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 style={styles.overlay} onClick={onClose}>
|
||||
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={styles.header}>
|
||||
<div>
|
||||
<h2 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={styles.loading}>Loading...</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: '#999',
|
||||
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: '#999',
|
||||
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
|
||||
Reference in New Issue
Block a user