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:
311
frontend/src/pages/WorkloadPage.tsx
Normal file
311
frontend/src/pages/WorkloadPage.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
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',
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user