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:
216
frontend/src/components/dashboard/HealthSummaryWidget.tsx
Normal file
216
frontend/src/components/dashboard/HealthSummaryWidget.tsx
Normal 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
|
||||
136
frontend/src/components/dashboard/QuickActions.tsx
Normal file
136
frontend/src/components/dashboard/QuickActions.tsx
Normal 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
|
||||
90
frontend/src/components/dashboard/StatisticsCard.tsx
Normal file
90
frontend/src/components/dashboard/StatisticsCard.tsx
Normal 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
|
||||
148
frontend/src/components/dashboard/WorkloadWidget.tsx
Normal file
148
frontend/src/components/dashboard/WorkloadWidget.tsx
Normal 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
|
||||
4
frontend/src/components/dashboard/index.ts
Normal file
4
frontend/src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { StatisticsCard } from './StatisticsCard'
|
||||
export { WorkloadWidget } from './WorkloadWidget'
|
||||
export { HealthSummaryWidget } from './HealthSummaryWidget'
|
||||
export { QuickActions } from './QuickActions'
|
||||
@@ -1,28 +1,171 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { dashboardApi, DashboardResponse } from '../services/dashboard'
|
||||
import {
|
||||
StatisticsCard,
|
||||
WorkloadWidget,
|
||||
HealthSummaryWidget,
|
||||
QuickActions,
|
||||
} from '../components/dashboard'
|
||||
import { Skeleton } from '../components/Skeleton'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user } = useAuth()
|
||||
const [data, setData] = useState<DashboardResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchDashboard = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await dashboardApi.getDashboard()
|
||||
setData(response)
|
||||
} catch (err) {
|
||||
setError('Failed to load dashboard data. Please try again.')
|
||||
console.error('Dashboard fetch error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboard()
|
||||
}, [fetchDashboard])
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.welcomeSection}>
|
||||
<Skeleton variant="text" width={200} height={28} />
|
||||
<Skeleton variant="text" width={150} height={16} style={{ marginTop: 8 }} />
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards Skeleton */}
|
||||
<div style={styles.statsGrid}>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} style={styles.skeletonCard}>
|
||||
<Skeleton variant="circle" width={48} height={48} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Skeleton variant="text" width="60%" height={28} />
|
||||
<Skeleton variant="text" width="80%" height={14} style={{ marginTop: 4 }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Widgets Skeleton */}
|
||||
<div style={styles.widgetsGrid}>
|
||||
<div style={styles.skeletonWidget}>
|
||||
<Skeleton variant="text" width={120} height={20} />
|
||||
<Skeleton variant="text" width="100%" height={32} style={{ marginTop: 16 }} />
|
||||
<Skeleton variant="rect" width="100%" height={8} style={{ marginTop: 12, borderRadius: 4 }} />
|
||||
</div>
|
||||
<div style={styles.skeletonWidget}>
|
||||
<Skeleton variant="text" width={120} height={20} />
|
||||
<div style={{ display: 'flex', gap: 24, marginTop: 16 }}>
|
||||
<Skeleton variant="circle" width={80} height={80} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Skeleton variant="text" width="100%" height={16} />
|
||||
<Skeleton variant="text" width="100%" height={16} style={{ marginTop: 8 }} />
|
||||
<Skeleton variant="text" width="100%" height={16} style={{ marginTop: 8 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Skeleton */}
|
||||
<div style={styles.skeletonWidget}>
|
||||
<Skeleton variant="text" width={120} height={20} />
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 16 }}>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} variant="rect" width={100} height={80} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.welcomeSection}>
|
||||
<h1 style={styles.welcomeTitle}>Welcome, {user?.name}!</h1>
|
||||
</div>
|
||||
|
||||
<div style={styles.errorCard}>
|
||||
<div style={styles.errorIcon}>!</div>
|
||||
<h3 style={styles.errorTitle}>Unable to Load Dashboard</h3>
|
||||
<p style={styles.errorMessage}>{error}</p>
|
||||
<button
|
||||
style={styles.retryButton}
|
||||
onClick={fetchDashboard}
|
||||
type="button"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Success state
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.welcomeCard}>
|
||||
<h2>Welcome, {user?.name}!</h2>
|
||||
<p>Email: {user?.email}</p>
|
||||
<p>Role: {user?.role || 'No role assigned'}</p>
|
||||
{user?.is_system_admin && (
|
||||
<p style={styles.adminNote}>
|
||||
You have system administrator privileges.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={styles.infoCard}>
|
||||
<h3>Getting Started</h3>
|
||||
<p>
|
||||
This is the Project Control system dashboard. Features will be
|
||||
added as development progresses.
|
||||
{/* Welcome Section */}
|
||||
<div style={styles.welcomeSection}>
|
||||
<h1 style={styles.welcomeTitle}>Welcome, {user?.name}!</h1>
|
||||
<p style={styles.welcomeSubtitle}>
|
||||
Here is your work overview for today
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
{data && (
|
||||
<>
|
||||
<div style={styles.statsGrid}>
|
||||
<StatisticsCard
|
||||
icon="✓"
|
||||
value={data.task_stats.assigned_count}
|
||||
label="My Tasks"
|
||||
color="#2196f3"
|
||||
/>
|
||||
<StatisticsCard
|
||||
icon="⏰"
|
||||
value={data.task_stats.due_this_week}
|
||||
label="Due This Week"
|
||||
color="#ff9800"
|
||||
highlight={data.task_stats.due_this_week > 0}
|
||||
/>
|
||||
<StatisticsCard
|
||||
icon="⚠"
|
||||
value={data.task_stats.overdue_count}
|
||||
label="Overdue"
|
||||
color="#f44336"
|
||||
highlight={data.task_stats.overdue_count > 0}
|
||||
/>
|
||||
<StatisticsCard
|
||||
icon="✅"
|
||||
value={data.task_stats.completion_rate}
|
||||
label="Completion Rate"
|
||||
color="#4caf50"
|
||||
suffix="%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Widgets Grid */}
|
||||
<div style={styles.widgetsGrid}>
|
||||
<WorkloadWidget workload={data.workload} />
|
||||
<HealthSummaryWidget health={data.health_summary} />
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<QuickActions isAdmin={user?.is_system_admin} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -32,23 +175,93 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
padding: '24px',
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
},
|
||||
welcomeCard: {
|
||||
welcomeSection: {
|
||||
marginBottom: '8px',
|
||||
},
|
||||
welcomeTitle: {
|
||||
margin: 0,
|
||||
fontSize: '24px',
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
},
|
||||
welcomeSubtitle: {
|
||||
margin: '8px 0 0 0',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
},
|
||||
statsGrid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
||||
gap: '16px',
|
||||
},
|
||||
widgetsGrid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: '16px',
|
||||
},
|
||||
skeletonCard: {
|
||||
backgroundColor: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
marginBottom: '24px',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
},
|
||||
adminNote: {
|
||||
color: '#0066cc',
|
||||
skeletonWidget: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
padding: '20px',
|
||||
},
|
||||
errorCard: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
},
|
||||
errorIcon: {
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#ffebee',
|
||||
color: '#f44336',
|
||||
fontSize: '32px',
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
errorTitle: {
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
},
|
||||
errorMessage: {
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
maxWidth: '400px',
|
||||
},
|
||||
retryButton: {
|
||||
padding: '10px 24px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
marginTop: '12px',
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
color: 'white',
|
||||
backgroundColor: '#2196f3',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s ease',
|
||||
},
|
||||
}
|
||||
|
||||
46
frontend/src/services/dashboard.ts
Normal file
46
frontend/src/services/dashboard.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import api from './api'
|
||||
|
||||
// Types for Dashboard API responses
|
||||
export type LoadLevel = 'normal' | 'warning' | 'overloaded' | 'unavailable'
|
||||
|
||||
export interface TaskStatistics {
|
||||
assigned_count: number
|
||||
due_this_week: number
|
||||
overdue_count: number
|
||||
completion_rate: number
|
||||
}
|
||||
|
||||
export interface WorkloadSummary {
|
||||
allocated_hours: number
|
||||
capacity_hours: number
|
||||
load_percentage: number
|
||||
load_level: LoadLevel
|
||||
}
|
||||
|
||||
export interface HealthSummary {
|
||||
total_projects: number
|
||||
healthy_count: number
|
||||
at_risk_count: number
|
||||
critical_count: number
|
||||
average_health_score: number
|
||||
projects_with_blockers: number
|
||||
}
|
||||
|
||||
export interface DashboardResponse {
|
||||
task_stats: TaskStatistics
|
||||
workload: WorkloadSummary
|
||||
health_summary: HealthSummary
|
||||
}
|
||||
|
||||
// API functions
|
||||
export const dashboardApi = {
|
||||
/**
|
||||
* Get aggregated dashboard data for the current user
|
||||
*/
|
||||
getDashboard: async (): Promise<DashboardResponse> => {
|
||||
const response = await api.get<DashboardResponse>('/dashboard')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default dashboardApi
|
||||
Reference in New Issue
Block a user