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>
325 lines
8.5 KiB
TypeScript
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
|