Files
PROJECT-CONTORL/frontend/src/components/ProjectHealthCard.tsx
beabigegg 55f85d0d3c feat: implement soft delete, task editing fixes, and UI improvements
Backend:
- Add soft delete for spaces and projects (is_active flag)
- Add status_id and assignee_id to TaskUpdate schema
- Fix task PATCH endpoint to update status and assignee
- Add validation for assignee_id and status_id in task updates
- Fix health service to count tasks with "Blocked" status as blockers
- Filter out deleted spaces/projects from health dashboard
- Add workload cache invalidation on assignee changes

Frontend:
- Add delete confirmation dialogs for spaces and projects
- Fix UserSelect to display selected user name (valueName prop)
- Fix task detail modal to refresh data after save
- Enforce 2-level subtask depth limit in UI
- Fix timezone bug in date formatting (use local timezone)
- Convert NotificationBell from Tailwind to inline styles
- Add i18n translations for health, workload, settings pages
- Add parent_task_id to Task interface across components

OpenSpec:
- Archive add-delete-capability change

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 01:32:13 +08:00

325 lines
8.5 KiB
TypeScript

import { useTranslation } from 'react-i18next'
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
const riskLevelColors: Record<RiskLevel, { color: string; bgColor: string }> = {
low: { color: '#2e7d32', bgColor: '#e8f5e9' },
medium: { color: '#f57c00', bgColor: '#fff3e0' },
high: { color: '#d84315', bgColor: '#fbe9e7' },
critical: { color: '#c62828', bgColor: '#ffebee' },
}
// Schedule status translation keys
const scheduleStatusKeys: Record<ScheduleStatus, string> = {
on_track: 'status.onTrack',
at_risk: 'status.atRisk',
delayed: 'status.delayed',
}
// Resource status translation keys
const resourceStatusKeys: Record<ResourceStatus, string> = {
adequate: 'resourceStatus.adequate',
constrained: 'resourceStatus.constrained',
overloaded: 'resourceStatus.overloaded',
}
export function ProjectHealthCard({ project, onClick }: ProjectHealthCardProps) {
const { t } = useTranslation('health')
const healthColor = getHealthScoreColor(project.health_score)
const riskColors = riskLevelColors[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: riskColors.color,
backgroundColor: riskColors.bgColor,
}}
>
{t(`riskLevel.${project.risk_level}`)}
</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}>{t('card.health')}</span>
</div>
</div>
<div style={styles.statusSection}>
<div style={styles.statusItem}>
<span style={styles.statusLabel}>{t('card.schedule')}</span>
<span style={styles.statusValue}>
{t(scheduleStatusKeys[project.schedule_status])}
</span>
</div>
<div style={styles.statusItem}>
<span style={styles.statusLabel}>{t('card.resources')}</span>
<span style={styles.statusValue}>
{t(resourceStatusKeys[project.resource_status])}
</span>
</div>
{project.owner_name && (
<div style={styles.statusItem}>
<span style={styles.statusLabel}>{t('card.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}>{t('card.taskProgress')}</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}>{t('card.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}>{t('card.overdue')}</span>
</div>
<div style={styles.metricItem}>
<span style={styles.metricValue}>{progressPercent}%</span>
<span style={styles.metricLabel}>{t('card.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