feat: implement dashboard widgets functionality

Backend:
- Add dashboard API router with widget endpoints
- Create dashboard schemas for widget data
- Add dashboard tests

Frontend:
- Enhance Dashboard page with widget components
- Add dashboard service for API calls
- Create reusable dashboard components

OpenSpec:
- Archive add-dashboard-widgets change
- Add dashboard capability specs

🤖 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-08 22:52:28 +08:00
parent 3d678ba5b0
commit 4860704543
17 changed files with 2152 additions and 28 deletions

View File

@@ -0,0 +1,216 @@
import React from 'react'
import { HealthSummary } from '../../services/dashboard'
interface HealthSummaryWidgetProps {
health: HealthSummary
}
// Health score color helper
function getHealthScoreColor(score: number): string {
if (score >= 80) return '#4caf50' // Green
if (score >= 60) return '#ff9800' // Orange
if (score >= 40) return '#ff5722' // Deep Orange
return '#f44336' // Red
}
export function HealthSummaryWidget({ health }: HealthSummaryWidgetProps) {
const scoreColor = getHealthScoreColor(health.average_health_score)
return (
<div style={styles.card}>
<div style={styles.header}>
<h3 style={styles.title}>Project Health</h3>
<span style={styles.totalBadge}>
{health.total_projects} projects
</span>
</div>
<div style={styles.content}>
{/* Average Score Display */}
<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={scoreColor}
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={`${(health.average_health_score / 100) * 220} 220`}
transform="rotate(-90 40 40)"
/>
</svg>
<div style={styles.scoreText}>
<span style={{ ...styles.scoreValue, color: scoreColor }}>
{Math.round(health.average_health_score)}
</span>
<span style={styles.scoreLabel}>Avg Score</span>
</div>
</div>
{/* Status Breakdown */}
<div style={styles.breakdown}>
<div style={styles.breakdownItem}>
<span style={{ ...styles.statusDot, backgroundColor: '#4caf50' }} />
<span style={styles.breakdownLabel}>Healthy</span>
<span style={styles.breakdownValue}>{health.healthy_count}</span>
</div>
<div style={styles.breakdownItem}>
<span style={{ ...styles.statusDot, backgroundColor: '#ff9800' }} />
<span style={styles.breakdownLabel}>At Risk</span>
<span style={styles.breakdownValue}>{health.at_risk_count}</span>
</div>
<div style={styles.breakdownItem}>
<span style={{ ...styles.statusDot, backgroundColor: '#f44336' }} />
<span style={styles.breakdownLabel}>Critical</span>
<span style={styles.breakdownValue}>{health.critical_count}</span>
</div>
</div>
</div>
{/* Blockers Info */}
{health.projects_with_blockers > 0 && (
<div style={styles.blockersInfo}>
<span style={styles.blockersIcon}>!</span>
<span style={styles.blockersText}>
{health.projects_with_blockers} project{health.projects_with_blockers > 1 ? 's have' : ' has'} blockers
</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',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
},
title: {
margin: 0,
fontSize: '16px',
fontWeight: 600,
color: '#333',
},
totalBadge: {
padding: '4px 10px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 500,
color: '#666',
backgroundColor: '#f5f5f5',
},
content: {
display: 'flex',
flexDirection: 'column',
gap: '16px',
},
scoreSection: {
display: 'flex',
alignItems: 'center',
gap: '24px',
},
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: '9px',
color: '#666',
textTransform: 'uppercase',
letterSpacing: '0.5px',
},
breakdown: {
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '10px',
},
breakdownItem: {
display: 'flex',
alignItems: 'center',
gap: '8px',
},
statusDot: {
width: '10px',
height: '10px',
borderRadius: '50%',
flexShrink: 0,
},
breakdownLabel: {
flex: 1,
fontSize: '13px',
color: '#666',
},
breakdownValue: {
fontSize: '14px',
fontWeight: 600,
color: '#333',
minWidth: '24px',
textAlign: 'right',
},
blockersInfo: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 12px',
backgroundColor: '#fff3e0',
borderRadius: '6px',
},
blockersIcon: {
width: '20px',
height: '20px',
borderRadius: '50%',
backgroundColor: '#ff9800',
color: 'white',
fontSize: '12px',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
blockersText: {
fontSize: '13px',
color: '#e65100',
fontWeight: 500,
},
}
export default HealthSummaryWidget

View File

@@ -0,0 +1,136 @@
import React from 'react'
import { Link } from 'react-router-dom'
interface QuickActionsProps {
isAdmin?: boolean
}
interface ActionItem {
to: string
label: string
icon: string
color: string
description: string
adminOnly?: boolean
}
const actions: ActionItem[] = [
{
to: '/spaces',
label: 'Spaces',
icon: '\u25a0', // Square icon
color: '#2196f3',
description: 'Browse projects',
},
{
to: '/workload',
label: 'Workload',
icon: '\u25b2', // Triangle icon
color: '#9c27b0',
description: 'View team capacity',
},
{
to: '/health',
label: 'Health',
icon: '\u2665', // Heart icon
color: '#4caf50',
description: 'Project status',
},
{
to: '/audit',
label: 'Audit',
icon: '\u25cf', // Circle icon
color: '#ff9800',
description: 'Activity logs',
adminOnly: true,
},
]
export function QuickActions({ isAdmin = false }: QuickActionsProps) {
const visibleActions = actions.filter((action) => !action.adminOnly || isAdmin)
return (
<div style={styles.card}>
<h3 style={styles.title}>Quick Actions</h3>
<div style={styles.grid}>
{visibleActions.map((action) => (
<Link
key={action.to}
to={action.to}
style={styles.actionLink}
>
<div
style={{
...styles.iconContainer,
backgroundColor: `${action.color}15`,
}}
>
<span style={{ ...styles.icon, color: action.color }}>
{action.icon}
</span>
</div>
<span style={styles.label}>{action.label}</span>
<span style={styles.description}>{action.description}</span>
</Link>
))}
</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',
},
title: {
margin: '0 0 16px 0',
fontSize: '16px',
fontWeight: 600,
color: '#333',
},
grid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
gap: '12px',
},
actionLink: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '16px 12px',
borderRadius: '8px',
border: '1px solid #e0e0e0',
textDecoration: 'none',
transition: 'all 0.2s ease',
cursor: 'pointer',
},
iconContainer: {
width: '40px',
height: '40px',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '8px',
},
icon: {
fontSize: '20px',
lineHeight: 1,
},
label: {
fontSize: '14px',
fontWeight: 600,
color: '#333',
marginBottom: '4px',
},
description: {
fontSize: '11px',
color: '#666',
textAlign: 'center',
},
}
export default QuickActions

View File

@@ -0,0 +1,90 @@
import React from 'react'
interface StatisticsCardProps {
icon: React.ReactNode
value: number | string
label: string
color?: string
suffix?: string
highlight?: boolean
}
export function StatisticsCard({
icon,
value,
label,
color = '#333',
suffix = '',
highlight = false,
}: StatisticsCardProps) {
return (
<div
style={{
...styles.card,
borderLeft: highlight ? `4px solid ${color}` : '4px solid transparent',
}}
>
<div style={{ ...styles.iconContainer, backgroundColor: `${color}15` }}>
<span style={{ ...styles.icon, color }}>{icon}</span>
</div>
<div style={styles.content}>
<span style={{ ...styles.value, color }}>
{value}
{suffix && <span style={styles.suffix}>{suffix}</span>}
</span>
<span style={styles.label}>{label}</span>
</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',
display: 'flex',
alignItems: 'center',
gap: '16px',
transition: 'box-shadow 0.2s ease',
},
iconContainer: {
width: '48px',
height: '48px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
icon: {
fontSize: '24px',
lineHeight: 1,
},
content: {
display: 'flex',
flexDirection: 'column',
gap: '4px',
minWidth: 0,
},
value: {
fontSize: '28px',
fontWeight: 700,
lineHeight: 1.2,
},
suffix: {
fontSize: '16px',
fontWeight: 500,
marginLeft: '2px',
},
label: {
fontSize: '14px',
color: '#666',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
}
export default StatisticsCard

View File

@@ -0,0 +1,148 @@
import React from 'react'
import { WorkloadSummary, LoadLevel } from '../../services/dashboard'
interface WorkloadWidgetProps {
workload: WorkloadSummary
}
// Load level configuration
const loadLevelConfig: Record<LoadLevel, { color: string; bgColor: string; label: string }> = {
normal: { color: '#4caf50', bgColor: '#e8f5e9', label: 'Normal' },
warning: { color: '#ff9800', bgColor: '#fff3e0', label: 'Warning' },
overloaded: { color: '#f44336', bgColor: '#ffebee', label: 'Overloaded' },
unavailable: { color: '#9e9e9e', bgColor: '#f5f5f5', label: 'Unavailable' },
}
export function WorkloadWidget({ workload }: WorkloadWidgetProps) {
const config = loadLevelConfig[workload.load_level]
const percentage = Math.min(workload.load_percentage, 100)
return (
<div style={styles.card}>
<div style={styles.header}>
<h3 style={styles.title}>My Workload</h3>
<span
style={{
...styles.badge,
color: config.color,
backgroundColor: config.bgColor,
}}
>
{config.label}
</span>
</div>
<div style={styles.content}>
<div style={styles.hoursDisplay}>
<span style={styles.hoursValue}>{workload.allocated_hours}h</span>
<span style={styles.hoursDivider}>/</span>
<span style={styles.hoursCapacity}>{workload.capacity_hours}h</span>
</div>
<div style={styles.progressContainer}>
<div style={styles.progressBar}>
<div
style={{
...styles.progressFill,
width: `${percentage}%`,
backgroundColor: config.color,
}}
/>
</div>
<span style={{ ...styles.percentage, color: config.color }}>
{workload.load_percentage}%
</span>
</div>
<p style={styles.description}>
{workload.load_level === 'normal' && 'You have capacity for additional tasks this week.'}
{workload.load_level === 'warning' && 'You are approaching full capacity this week.'}
{workload.load_level === 'overloaded' && 'You are over capacity. Consider reassigning tasks.'}
{workload.load_level === 'unavailable' && 'Workload data is not available.'}
</p>
</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',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
},
title: {
margin: 0,
fontSize: '16px',
fontWeight: 600,
color: '#333',
},
badge: {
padding: '4px 10px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 500,
},
content: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
},
hoursDisplay: {
display: 'flex',
alignItems: 'baseline',
gap: '4px',
},
hoursValue: {
fontSize: '32px',
fontWeight: 700,
color: '#333',
},
hoursDivider: {
fontSize: '20px',
color: '#999',
margin: '0 4px',
},
hoursCapacity: {
fontSize: '20px',
color: '#666',
},
progressContainer: {
display: 'flex',
alignItems: 'center',
gap: '12px',
},
progressBar: {
flex: 1,
height: '8px',
backgroundColor: '#e0e0e0',
borderRadius: '4px',
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: '4px',
transition: 'width 0.3s ease',
},
percentage: {
fontSize: '14px',
fontWeight: 600,
minWidth: '45px',
textAlign: 'right',
},
description: {
margin: 0,
fontSize: '13px',
color: '#666',
lineHeight: 1.5,
},
}
export default WorkloadWidget

View File

@@ -0,0 +1,4 @@
export { StatisticsCard } from './StatisticsCard'
export { WorkloadWidget } from './WorkloadWidget'
export { HealthSummaryWidget } from './HealthSummaryWidget'
export { QuickActions } from './QuickActions'