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:
258
frontend/src/components/WorkloadHeatmap.tsx
Normal file
258
frontend/src/components/WorkloadHeatmap.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { WorkloadUser, LoadLevel } from '../services/workload'
|
||||
|
||||
interface WorkloadHeatmapProps {
|
||||
users: WorkloadUser[]
|
||||
weekStart: string
|
||||
weekEnd: string
|
||||
onUserClick: (userId: string, userName: string) => 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 WorkloadHeatmap({ users, weekStart, weekEnd, onUserClick }: WorkloadHeatmapProps) {
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-TW', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
// Group users by department
|
||||
const groupedByDepartment = users.reduce((acc, user) => {
|
||||
const dept = user.department_name || 'No Department'
|
||||
if (!acc[dept]) {
|
||||
acc[dept] = []
|
||||
}
|
||||
acc[dept].push(user)
|
||||
return acc
|
||||
}, {} as Record<string, WorkloadUser[]>)
|
||||
|
||||
const departments = Object.keys(groupedByDepartment).sort()
|
||||
|
||||
if (users.length === 0) {
|
||||
return (
|
||||
<div style={styles.emptyState}>
|
||||
<p>No workload data available for this week.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<span style={styles.weekRange}>
|
||||
{formatDate(weekStart)} - {formatDate(weekEnd)}
|
||||
</span>
|
||||
<div style={styles.legend}>
|
||||
{(Object.keys(loadLevelColors) as LoadLevel[]).map((level) => (
|
||||
<div key={level} style={styles.legendItem}>
|
||||
<span
|
||||
style={{
|
||||
...styles.legendColor,
|
||||
backgroundColor: loadLevelColors[level],
|
||||
}}
|
||||
/>
|
||||
<span style={styles.legendLabel}>{loadLevelLabels[level]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.tableContainer}>
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>Team Member</th>
|
||||
<th style={styles.th}>Department</th>
|
||||
<th style={styles.th}>Allocated</th>
|
||||
<th style={styles.th}>Capacity</th>
|
||||
<th style={styles.th}>Load</th>
|
||||
<th style={styles.th}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{departments.map((dept) =>
|
||||
groupedByDepartment[dept].map((user, index) => (
|
||||
<tr
|
||||
key={user.user_id}
|
||||
style={{
|
||||
...styles.tr,
|
||||
backgroundColor: index % 2 === 0 ? '#fff' : '#fafafa',
|
||||
}}
|
||||
onClick={() => onUserClick(user.user_id, user.user_name)}
|
||||
>
|
||||
<td style={styles.td}>
|
||||
<span style={styles.userName}>{user.user_name}</span>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<span style={styles.department}>{user.department_name || '-'}</span>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<span style={styles.hours}>{user.allocated_hours}h</span>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<span style={styles.hours}>{user.capacity_hours}h</span>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<div style={styles.loadBarContainer}>
|
||||
<div
|
||||
style={{
|
||||
...styles.loadBar,
|
||||
width: `${Math.min(user.load_percentage, 100)}%`,
|
||||
backgroundColor: loadLevelColors[user.load_level],
|
||||
}}
|
||||
/>
|
||||
<span style={styles.loadPercentage}>{user.load_percentage}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<span
|
||||
style={{
|
||||
...styles.statusBadge,
|
||||
backgroundColor: loadLevelColors[user.load_level],
|
||||
}}
|
||||
>
|
||||
{loadLevelLabels[user.load_level]}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: { [key: string]: React.CSSProperties } = {
|
||||
container: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #eee',
|
||||
},
|
||||
weekRange: {
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
fontWeight: 500,
|
||||
},
|
||||
legend: {
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
},
|
||||
legendItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
},
|
||||
legendColor: {
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
legendLabel: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
},
|
||||
tableContainer: {
|
||||
overflowX: 'auto',
|
||||
},
|
||||
table: {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
},
|
||||
th: {
|
||||
textAlign: 'left',
|
||||
padding: '12px 16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#666',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderBottom: '1px solid #eee',
|
||||
},
|
||||
tr: {
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s ease',
|
||||
},
|
||||
td: {
|
||||
padding: '14px 16px',
|
||||
fontSize: '14px',
|
||||
borderBottom: '1px solid #eee',
|
||||
},
|
||||
userName: {
|
||||
fontWeight: 500,
|
||||
color: '#333',
|
||||
},
|
||||
department: {
|
||||
color: '#666',
|
||||
},
|
||||
hours: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '13px',
|
||||
},
|
||||
loadBarContainer: {
|
||||
position: 'relative',
|
||||
width: '120px',
|
||||
height: '24px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
loadBar: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
borderRadius: '4px',
|
||||
transition: 'width 0.3s ease',
|
||||
},
|
||||
loadPercentage: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
textShadow: '0 0 2px rgba(255, 255, 255, 0.8)',
|
||||
},
|
||||
statusBadge: {
|
||||
display: 'inline-block',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: 'white',
|
||||
},
|
||||
emptyState: {
|
||||
textAlign: 'center',
|
||||
padding: '48px',
|
||||
color: '#666',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
}
|
||||
|
||||
export default WorkloadHeatmap
|
||||
Reference in New Issue
Block a user