Files
PROJECT-CONTORL/frontend/src/pages/WorkloadPage.tsx
beabigegg 9b220523ff 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>
2026-01-04 21:49:52 +08:00

312 lines
8.2 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react'
import { WorkloadHeatmap } from '../components/WorkloadHeatmap'
import { WorkloadUserDetail } from '../components/WorkloadUserDetail'
import { workloadApi, WorkloadHeatmapResponse } from '../services/workload'
// Helper to get Monday of a given week
function getMonday(date: Date): Date {
const d = new Date(date)
const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
d.setDate(diff)
d.setHours(0, 0, 0, 0)
return d
}
// Format date as YYYY-MM-DD
function formatDateParam(date: Date): string {
return date.toISOString().split('T')[0]
}
// Format date for display
function formatWeekDisplay(date: Date): string {
return date.toLocaleDateString('zh-TW', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export default function WorkloadPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [heatmapData, setHeatmapData] = useState<WorkloadHeatmapResponse | null>(null)
const [selectedWeek, setSelectedWeek] = useState<Date>(() => getMonday(new Date()))
const [selectedUser, setSelectedUser] = useState<{ id: string; name: string } | null>(null)
const [showUserDetail, setShowUserDetail] = useState(false)
const loadHeatmap = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await workloadApi.getHeatmap(formatDateParam(selectedWeek))
setHeatmapData(data)
} catch (err) {
console.error('Failed to load workload heatmap:', err)
setError('Failed to load workload data. Please try again.')
} finally {
setLoading(false)
}
}, [selectedWeek])
useEffect(() => {
loadHeatmap()
}, [loadHeatmap])
const handlePrevWeek = () => {
setSelectedWeek((prev) => {
const newDate = new Date(prev)
newDate.setDate(newDate.getDate() - 7)
return newDate
})
}
const handleNextWeek = () => {
setSelectedWeek((prev) => {
const newDate = new Date(prev)
newDate.setDate(newDate.getDate() + 7)
return newDate
})
}
const handleToday = () => {
setSelectedWeek(getMonday(new Date()))
}
const handleUserClick = (userId: string, userName: string) => {
setSelectedUser({ id: userId, name: userName })
setShowUserDetail(true)
}
const handleCloseUserDetail = () => {
setShowUserDetail(false)
setSelectedUser(null)
}
const isCurrentWeek = () => {
const currentMonday = getMonday(new Date())
return selectedWeek.getTime() === currentMonday.getTime()
}
return (
<div style={styles.container}>
<div style={styles.header}>
<div>
<h1 style={styles.title}>Team Workload</h1>
<p style={styles.subtitle}>
Monitor team capacity and task distribution
</p>
</div>
</div>
{/* Week Navigation */}
<div style={styles.weekNav}>
<button onClick={handlePrevWeek} style={styles.navButton} aria-label="Previous week">
&larr; Previous
</button>
<div style={styles.weekDisplay}>
<span style={styles.weekLabel}>Week of</span>
<span style={styles.weekDate}>{formatWeekDisplay(selectedWeek)}</span>
</div>
<button onClick={handleNextWeek} style={styles.navButton} aria-label="Next week">
Next &rarr;
</button>
{!isCurrentWeek() && (
<button onClick={handleToday} style={styles.todayButton}>
Today
</button>
)}
</div>
{/* Content */}
{loading ? (
<div style={styles.loadingContainer}>
<div style={styles.loading}>Loading workload data...</div>
</div>
) : error ? (
<div style={styles.errorContainer}>
<p style={styles.error}>{error}</p>
<button onClick={loadHeatmap} style={styles.retryButton}>
Retry
</button>
</div>
) : heatmapData ? (
<WorkloadHeatmap
users={heatmapData.users}
weekStart={heatmapData.week_start}
weekEnd={heatmapData.week_end}
onUserClick={handleUserClick}
/>
) : null}
{/* Summary Stats */}
{heatmapData && heatmapData.users.length > 0 && (
<div style={styles.statsContainer}>
<div style={styles.statCard}>
<span style={styles.statValue}>{heatmapData.users.length}</span>
<span style={styles.statLabel}>Team Members</span>
</div>
<div style={styles.statCard}>
<span style={styles.statValue}>
{heatmapData.users.filter((u) => u.load_level === 'overloaded').length}
</span>
<span style={styles.statLabel}>Overloaded</span>
</div>
<div style={styles.statCard}>
<span style={styles.statValue}>
{heatmapData.users.filter((u) => u.load_level === 'warning').length}
</span>
<span style={styles.statLabel}>At Risk</span>
</div>
<div style={styles.statCard}>
<span style={styles.statValue}>
{Math.round(
heatmapData.users.reduce((sum, u) => sum + u.load_percentage, 0) /
heatmapData.users.length
)}%
</span>
<span style={styles.statLabel}>Avg. Load</span>
</div>
</div>
)}
{/* User Detail Modal */}
{selectedUser && (
<WorkloadUserDetail
userId={selectedUser.id}
userName={selectedUser.name}
weekStart={formatDateParam(selectedWeek)}
isOpen={showUserDetail}
onClose={handleCloseUserDetail}
/>
)}
</div>
)
}
const styles: { [key: string]: React.CSSProperties } = {
container: {
padding: '24px',
maxWidth: '1200px',
margin: '0 auto',
},
header: {
marginBottom: '24px',
},
title: {
fontSize: '24px',
fontWeight: 600,
margin: 0,
color: '#333',
},
subtitle: {
fontSize: '14px',
color: '#666',
margin: '4px 0 0 0',
},
weekNav: {
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '24px',
padding: '16px 20px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
},
navButton: {
padding: '8px 16px',
backgroundColor: '#f5f5f5',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
color: '#333',
transition: 'background-color 0.2s',
},
weekDisplay: {
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
},
weekLabel: {
fontSize: '12px',
color: '#666',
textTransform: 'uppercase',
letterSpacing: '0.5px',
},
weekDate: {
fontSize: '16px',
fontWeight: 600,
color: '#333',
},
todayButton: {
padding: '8px 16px',
backgroundColor: '#0066cc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
},
loadingContainer: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
padding: '48px',
textAlign: 'center',
},
loading: {
color: '#666',
},
errorContainer: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
padding: '48px',
textAlign: 'center',
},
error: {
color: '#f44336',
marginBottom: '16px',
},
retryButton: {
padding: '10px 20px',
backgroundColor: '#0066cc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
},
statsContainer: {
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '16px',
marginTop: '24px',
},
statCard: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
padding: '20px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
},
statValue: {
fontSize: '28px',
fontWeight: 600,
color: '#333',
},
statLabel: {
fontSize: '12px',
color: '#666',
textTransform: 'uppercase',
letterSpacing: '0.5px',
},
}