## 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>
312 lines
8.2 KiB
TypeScript
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">
|
|
← 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 →
|
|
</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',
|
|
},
|
|
}
|