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:
beabigegg
2026-01-04 21:49:52 +08:00
parent 64874d5425
commit 9b220523ff
90 changed files with 9426 additions and 194 deletions

View 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