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:
322
frontend/src/components/ProjectHealthCard.tsx
Normal file
322
frontend/src/components/ProjectHealthCard.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { ProjectHealthItem, RiskLevel, ScheduleStatus, ResourceStatus } from '../services/projectHealth'
|
||||
|
||||
interface ProjectHealthCardProps {
|
||||
project: ProjectHealthItem
|
||||
onClick?: (projectId: string) => void
|
||||
}
|
||||
|
||||
// Color mapping for health scores
|
||||
function getHealthScoreColor(score: number): string {
|
||||
if (score >= 80) return '#4caf50' // Green
|
||||
if (score >= 60) return '#ff9800' // Yellow/Orange
|
||||
if (score >= 40) return '#ff5722' // Orange
|
||||
return '#f44336' // Red
|
||||
}
|
||||
|
||||
// Risk level colors and labels
|
||||
const riskLevelConfig: Record<RiskLevel, { color: string; bgColor: string; label: string }> = {
|
||||
low: { color: '#2e7d32', bgColor: '#e8f5e9', label: 'Low Risk' },
|
||||
medium: { color: '#f57c00', bgColor: '#fff3e0', label: 'Medium Risk' },
|
||||
high: { color: '#d84315', bgColor: '#fbe9e7', label: 'High Risk' },
|
||||
critical: { color: '#c62828', bgColor: '#ffebee', label: 'Critical' },
|
||||
}
|
||||
|
||||
// Schedule status labels
|
||||
const scheduleStatusLabels: Record<ScheduleStatus, string> = {
|
||||
on_track: 'On Track',
|
||||
at_risk: 'At Risk',
|
||||
delayed: 'Delayed',
|
||||
}
|
||||
|
||||
// Resource status labels
|
||||
const resourceStatusLabels: Record<ResourceStatus, string> = {
|
||||
adequate: 'Adequate',
|
||||
constrained: 'Constrained',
|
||||
overloaded: 'Overloaded',
|
||||
}
|
||||
|
||||
export function ProjectHealthCard({ project, onClick }: ProjectHealthCardProps) {
|
||||
const healthColor = getHealthScoreColor(project.health_score)
|
||||
const riskConfig = riskLevelConfig[project.risk_level]
|
||||
const progressPercent = project.task_count > 0
|
||||
? Math.round((project.completed_task_count / project.task_count) * 100)
|
||||
: 0
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick(project.project_id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={styles.card}
|
||||
onClick={handleClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleClick()
|
||||
}
|
||||
}}
|
||||
aria-label={`Project ${project.project_title}, health score ${project.health_score}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={styles.header}>
|
||||
<div style={styles.titleSection}>
|
||||
<h3 style={styles.title}>{project.project_title}</h3>
|
||||
{project.space_name && (
|
||||
<span style={styles.spaceName}>{project.space_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
...styles.riskBadge,
|
||||
color: riskConfig.color,
|
||||
backgroundColor: riskConfig.bgColor,
|
||||
}}
|
||||
>
|
||||
{riskConfig.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health Score */}
|
||||
<div style={styles.scoreSection}>
|
||||
<div style={styles.scoreCircle}>
|
||||
<svg width="80" height="80" viewBox="0 0 80 80">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="35"
|
||||
fill="none"
|
||||
stroke="#e0e0e0"
|
||||
strokeWidth="6"
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="35"
|
||||
fill="none"
|
||||
stroke={healthColor}
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${(project.health_score / 100) * 220} 220`}
|
||||
transform="rotate(-90 40 40)"
|
||||
/>
|
||||
</svg>
|
||||
<div style={styles.scoreText}>
|
||||
<span style={{ ...styles.scoreValue, color: healthColor }}>
|
||||
{project.health_score}
|
||||
</span>
|
||||
<span style={styles.scoreLabel}>Health</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.statusSection}>
|
||||
<div style={styles.statusItem}>
|
||||
<span style={styles.statusLabel}>Schedule</span>
|
||||
<span style={styles.statusValue}>
|
||||
{scheduleStatusLabels[project.schedule_status]}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.statusItem}>
|
||||
<span style={styles.statusLabel}>Resources</span>
|
||||
<span style={styles.statusValue}>
|
||||
{resourceStatusLabels[project.resource_status]}
|
||||
</span>
|
||||
</div>
|
||||
{project.owner_name && (
|
||||
<div style={styles.statusItem}>
|
||||
<span style={styles.statusLabel}>Owner</span>
|
||||
<span style={styles.statusValue}>{project.owner_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Progress */}
|
||||
<div style={styles.progressSection}>
|
||||
<div style={styles.progressHeader}>
|
||||
<span style={styles.progressLabel}>Task Progress</span>
|
||||
<span style={styles.progressValue}>
|
||||
{project.completed_task_count} / {project.task_count}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.progressBarContainer}>
|
||||
<div
|
||||
style={{
|
||||
...styles.progressBar,
|
||||
width: `${progressPercent}%`,
|
||||
backgroundColor: healthColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div style={styles.metricsSection}>
|
||||
<div style={styles.metricItem}>
|
||||
<span style={styles.metricValue}>{project.blocker_count}</span>
|
||||
<span style={styles.metricLabel}>Blockers</span>
|
||||
</div>
|
||||
<div style={styles.metricItem}>
|
||||
<span style={{ ...styles.metricValue, color: project.overdue_task_count > 0 ? '#f44336' : 'inherit' }}>
|
||||
{project.overdue_task_count}
|
||||
</span>
|
||||
<span style={styles.metricLabel}>Overdue</span>
|
||||
</div>
|
||||
<div style={styles.metricItem}>
|
||||
<span style={styles.metricValue}>{progressPercent}%</span>
|
||||
<span style={styles.metricLabel}>Complete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: { [key: string]: React.CSSProperties } = {
|
||||
card: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
padding: '20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'box-shadow 0.2s ease, transform 0.2s ease',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
titleSection: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
title: {
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
spaceName: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
marginTop: '4px',
|
||||
display: 'block',
|
||||
},
|
||||
riskBadge: {
|
||||
padding: '4px 10px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginLeft: '12px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
scoreSection: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
scoreCircle: {
|
||||
position: 'relative',
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
scoreText: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center',
|
||||
},
|
||||
scoreValue: {
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
display: 'block',
|
||||
lineHeight: 1,
|
||||
},
|
||||
scoreLabel: {
|
||||
fontSize: '10px',
|
||||
color: '#666',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
},
|
||||
statusSection: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
},
|
||||
statusItem: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
statusLabel: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
},
|
||||
statusValue: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#333',
|
||||
},
|
||||
progressSection: {
|
||||
marginBottom: '16px',
|
||||
},
|
||||
progressHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '8px',
|
||||
},
|
||||
progressLabel: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
},
|
||||
progressValue: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#333',
|
||||
},
|
||||
progressBarContainer: {
|
||||
height: '6px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBar: {
|
||||
height: '100%',
|
||||
borderRadius: '3px',
|
||||
transition: 'width 0.3s ease',
|
||||
},
|
||||
metricsSection: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
paddingTop: '16px',
|
||||
borderTop: '1px solid #eee',
|
||||
},
|
||||
metricItem: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
display: 'block',
|
||||
},
|
||||
metricLabel: {
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
},
|
||||
}
|
||||
|
||||
export default ProjectHealthCard
|
||||
Reference in New Issue
Block a user