Files
mylder-frontend/src/components/project/health-widget.tsx
christiankrag 884bbb11fc Add design thinking components for project management
- Add PhaseNavigator with 5 design phases (Empathize, Define, Ideate, Prototype, Test)
- Add HealthWidget with circular progress and metrics
- Add BacklogBoard with WSJF prioritization display
- Add RecommendationsWidget for AI-powered insights
- Add Tooltip component (shadcn/ui)
- Add design-thinking types with phase configs and backlog item structure

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 19:54:05 +01:00

123 lines
4.0 KiB
TypeScript

'use client'
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Activity, AlertTriangle, CheckCircle2, Clock, TrendingUp } from 'lucide-react'
import type { ProjectHealth } from '@/types/design-thinking'
interface HealthWidgetProps {
health: ProjectHealth
className?: string
}
function HealthRing({ value, size = 120, strokeWidth = 8 }: { value: number; size?: number; strokeWidth?: number }) {
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (value / 100) * circumference
const getColor = (v: number) => {
if (v >= 80) return 'stroke-emerald-500'
if (v >= 60) return 'stroke-amber-500'
return 'stroke-red-500'
}
return (
<div className="relative" style={{ width: size, height: size }}>
<svg className="transform -rotate-90" width={size} height={size}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
strokeWidth={strokeWidth}
className="stroke-muted fill-none"
/>
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
strokeWidth={strokeWidth}
className={cn('fill-none', getColor(value))}
strokeLinecap="round"
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset: offset }}
transition={{ duration: 1, ease: 'easeOut' }}
style={{ strokeDasharray: circumference }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<motion.span
className="text-3xl font-bold"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.5 }}
>
{value}
</motion.span>
<span className="text-xs text-muted-foreground">Health Score</span>
</div>
</div>
)
}
export function HealthWidget({ health, className }: HealthWidgetProps) {
const metrics = [
{
label: 'Velocity',
value: health.velocity,
icon: TrendingUp,
color: health.velocity >= 80 ? 'text-emerald-500' : health.velocity >= 60 ? 'text-amber-500' : 'text-red-500'
},
{
label: 'Completion',
value: health.completion_rate,
icon: CheckCircle2,
color: health.completion_rate >= 80 ? 'text-emerald-500' : health.completion_rate >= 60 ? 'text-amber-500' : 'text-red-500',
suffix: '%'
},
{
label: 'Blockers',
value: health.blockers,
icon: AlertTriangle,
color: health.blockers === 0 ? 'text-emerald-500' : health.blockers <= 2 ? 'text-amber-500' : 'text-red-500',
inverse: true
},
{
label: 'Overdue',
value: health.overdue,
icon: Clock,
color: health.overdue === 0 ? 'text-emerald-500' : health.overdue <= 2 ? 'text-amber-500' : 'text-red-500',
inverse: true
}
]
return (
<Card className={className}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Activity className="w-4 h-4" />
Project Health
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-6">
<HealthRing value={health.overall} />
<div className="grid grid-cols-2 gap-4 flex-1">
{metrics.map((metric) => (
<div key={metric.label} className="space-y-1">
<div className="flex items-center gap-1.5">
<metric.icon className={cn('w-3.5 h-3.5', metric.color)} />
<span className="text-xs text-muted-foreground">{metric.label}</span>
</div>
<p className={cn('text-lg font-semibold', metric.color)}>
{metric.value}{metric.suffix || ''}
</p>
</div>
))}
</div>
</div>
</CardContent>
</Card>
)
}