Add agentic dashboard and phase tracking UI components

- Add project tabs with chat and dashboard views
- Add agentic dashboard with progress timeline
- Add phase indicator and progress timeline components
- Add activity feed and metric card components
- Add status badge component

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-14 12:40:19 +01:00
parent eff740704b
commit 1035683b56
8 changed files with 764 additions and 7 deletions

View File

@@ -1,11 +1,11 @@
import { createClient } from '@/lib/supabase/server'
import { notFound } from 'next/navigation'
import { ProjectChat } from '@/components/chat/project-chat'
import { Badge } from '@/components/ui/badge'
import Link from 'next/link'
import { ArrowLeft, Settings } from 'lucide-react'
import { Button } from '@/components/ui/button'
import type { Database } from '@/types/database'
import { ProjectTabs } from './project-tabs'
type Project = Database['public']['Tables']['projects']['Row']
type Message = Database['public']['Tables']['messages']['Row']
@@ -24,7 +24,6 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
notFound()
}
// Get messages for this project
const { data: messages } = await supabase
.from('messages')
.select('*')
@@ -33,7 +32,6 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
return (
<div className="h-[calc(100vh-4rem)] flex flex-col">
{/* Project Header */}
<div className="border-b bg-card px-4 py-3">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-4">
@@ -58,10 +56,11 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
</div>
</div>
{/* Chat Interface */}
<div className="flex-1 overflow-hidden">
<ProjectChat projectId={id} initialMessages={messages || []} />
</div>
<ProjectTabs
projectId={id}
projectName={project.name}
initialMessages={messages || []}
/>
</div>
)
}

View File

@@ -0,0 +1,55 @@
'use client'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ProjectChat } from '@/components/chat/project-chat'
import { AgenticDashboard } from '@/components/dashboard/agentic-dashboard'
import { MessageSquare, Workflow } from 'lucide-react'
import type { Database } from '@/types/database'
type Message = Database['public']['Tables']['messages']['Row']
interface ProjectTabsProps {
projectId: string
projectName: string
initialMessages: Message[]
}
export function ProjectTabs({ projectId, projectName, initialMessages }: ProjectTabsProps) {
return (
<Tabs defaultValue="chat" className="flex-1 flex flex-col overflow-hidden">
<div className="border-b bg-card/50 px-4">
<div className="max-w-7xl mx-auto">
<TabsList className="h-10 bg-transparent p-0 gap-4">
<TabsTrigger
value="chat"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-foreground rounded-none px-1 pb-3 pt-2"
>
<MessageSquare className="w-4 h-4 mr-2" />
Chat
</TabsTrigger>
<TabsTrigger
value="workflow"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-foreground rounded-none px-1 pb-3 pt-2"
>
<Workflow className="w-4 h-4 mr-2" />
Workflow
</TabsTrigger>
</TabsList>
</div>
</div>
<TabsContent value="chat" className="flex-1 overflow-hidden m-0 data-[state=inactive]:hidden">
<ProjectChat projectId={projectId} initialMessages={initialMessages} />
</TabsContent>
<TabsContent value="workflow" className="flex-1 overflow-auto m-0 p-6 data-[state=inactive]:hidden">
<div className="max-w-7xl mx-auto">
<AgenticDashboard
projectId={projectId}
projectName={projectName}
/>
</div>
</TabsContent>
</Tabs>
)
}

View File

@@ -0,0 +1,298 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { cn } from '@/lib/utils'
import { ProgressTimeline, PHASES, type Phase } from '@/components/ui/progress-timeline'
import { PhaseIndicator } from '@/components/ui/phase-indicator'
import { StatusBadge } from '@/components/ui/status-badge'
import { ActivityFeed, type Activity } from '@/components/ui/activity-feed'
import { MetricCard } from '@/components/ui/metric-card'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
Brain,
Search,
Lightbulb,
Map,
Hammer,
Rocket,
BarChart3,
Sparkles,
ChevronRight,
Play,
Pause,
RotateCcw,
Zap,
Clock,
Target,
TrendingUp
} from 'lucide-react'
const phaseIcons: Record<Phase, React.ReactNode> = {
think: <Brain className="w-5 h-5" />,
evaluate: <Search className="w-5 h-5" />,
ideate: <Lightbulb className="w-5 h-5" />,
plan: <Map className="w-5 h-5" />,
create: <Hammer className="w-5 h-5" />,
deploy: <Rocket className="w-5 h-5" />,
analyze: <BarChart3 className="w-5 h-5" />,
enhance: <Sparkles className="w-5 h-5" />
}
const phaseDescriptions: Record<Phase, string> = {
think: 'Define the problem space and user needs',
evaluate: 'Assess technical feasibility and risks',
ideate: 'Generate solution options through divergent thinking',
plan: 'Select best approach and break into tasks',
create: 'Build the MVP with iterative development',
deploy: 'Ship to production with monitoring',
analyze: 'Collect metrics and user feedback',
enhance: 'Iterate based on data insights'
}
interface AgenticDashboardProps {
projectId: string
projectName: string
initialPhase?: Phase
initialCompleted?: Phase[]
className?: string
}
export function AgenticDashboard({
projectName,
initialPhase = 'think',
initialCompleted = [],
className
}: AgenticDashboardProps) {
const [currentPhase, setCurrentPhase] = useState<Phase>(initialPhase)
const [completedPhases, setCompletedPhases] = useState<Phase[]>(initialCompleted)
const [isRunning, setIsRunning] = useState(false)
const [selectedPhase, setSelectedPhase] = useState<Phase | null>(null)
const [activities] = useState<Activity[]>([
{ id: '1', type: 'success', message: 'Project initialized successfully', timestamp: new Date(Date.now() - 3600000) },
{ id: '2', type: 'phase_change', message: 'Started Think phase', timestamp: new Date(Date.now() - 1800000) },
{ id: '3', type: 'action', message: 'Analyzing user requirements...', timestamp: new Date(Date.now() - 900000) },
])
const progress = (completedPhases.length / PHASES.length) * 100
const currentIndex = PHASES.indexOf(currentPhase)
const handleAdvancePhase = () => {
if (currentIndex < PHASES.length - 1) {
setCompletedPhases([...completedPhases, currentPhase])
setCurrentPhase(PHASES[currentIndex + 1])
} else {
setCompletedPhases([...completedPhases, currentPhase])
setCurrentPhase('think')
}
}
const handleReset = () => {
setCurrentPhase('think')
setCompletedPhases([])
setIsRunning(false)
}
return (
<div className={cn('space-y-6', className)}>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{projectName}</h2>
<div className="flex items-center gap-2 mt-1">
<StatusBadge variant={isRunning ? 'info' : 'default'} pulse={isRunning}>
{isRunning ? 'Running' : 'Paused'}
</StatusBadge>
<span className="text-sm text-muted-foreground">
Iteration #{Math.floor(completedPhases.length / PHASES.length) + 1}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsRunning(!isRunning)}
>
{isRunning ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</Button>
<Button variant="outline" size="sm" onClick={handleReset}>
<RotateCcw className="w-4 h-4" />
</Button>
<Button onClick={handleAdvancePhase} className="gap-2">
Advance <ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
{/* Progress Timeline */}
<Card>
<CardContent className="pt-6">
<ProgressTimeline
currentPhase={currentPhase}
completedPhases={completedPhases}
/>
<div className="flex items-center justify-center gap-2 mt-4 text-sm text-muted-foreground">
<span className="font-medium">{Math.round(progress)}%</span>
<span>complete</span>
</div>
</CardContent>
</Card>
{/* Phase Grid + Details */}
<div className="grid lg:grid-cols-3 gap-6">
{/* Phase Cards */}
<div className="lg:col-span-2 grid sm:grid-cols-2 gap-3">
{PHASES.map((phase) => {
const isCompleted = completedPhases.includes(phase)
const isCurrent = phase === currentPhase
const status = isCompleted ? 'completed' : isCurrent ? 'in_progress' : 'not_started'
return (
<motion.div
key={phase}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => setSelectedPhase(phase)}
>
<Card className={cn(
'cursor-pointer transition-all',
isCurrent && 'border-blue-500/50 bg-blue-500/5',
isCompleted && 'border-emerald-500/50 bg-emerald-500/5',
selectedPhase === phase && 'ring-2 ring-foreground/10'
)}>
<CardContent className="pt-4 pb-4">
<div className="flex items-start gap-3">
<div className={cn(
'p-2 rounded-lg',
isCurrent && 'bg-blue-500/10 text-blue-500',
isCompleted && 'bg-emerald-500/10 text-emerald-500',
!isCurrent && !isCompleted && 'bg-muted text-muted-foreground'
)}>
{phaseIcons[phase]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="font-medium capitalize">{phase}</span>
<PhaseIndicator phase={phase} status={status} showLabel={false} size="sm" />
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{phaseDescriptions[phase]}
</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
)
})}
</div>
{/* Activity Feed */}
<div>
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Zap className="w-4 h-4" />
Activity
</CardTitle>
</CardHeader>
<CardContent>
<ActivityFeed activities={activities} maxItems={5} />
</CardContent>
</Card>
</div>
</div>
{/* Metrics */}
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="Progress"
value={progress}
format="percent"
icon={<Target className="w-4 h-4" />}
/>
<MetricCard
label="Phases Complete"
value={completedPhases.length}
trend="up"
trendValue={`of ${PHASES.length}`}
icon={<TrendingUp className="w-4 h-4" />}
/>
<MetricCard
label="Time in Phase"
value={2.5}
format="duration"
icon={<Clock className="w-4 h-4" />}
/>
<MetricCard
label="Iterations"
value={Math.floor(completedPhases.length / PHASES.length) + 1}
icon={<RotateCcw className="w-4 h-4" />}
/>
</div>
{/* Phase Detail Panel */}
<AnimatePresence>
{selectedPhase && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-muted">
{phaseIcons[selectedPhase]}
</div>
<div>
<span className="capitalize">{selectedPhase}</span>
<p className="text-sm font-normal text-muted-foreground mt-0.5">
{phaseDescriptions[selectedPhase]}
</p>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid sm:grid-cols-3 gap-4">
<div>
<h4 className="text-sm font-medium mb-2">Entry Criteria</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Previous phase completed</li>
<li> Required inputs available</li>
<li> Team capacity confirmed</li>
</ul>
</div>
<div>
<h4 className="text-sm font-medium mb-2">Key Activities</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Research & discovery</li>
<li> Documentation</li>
<li> Stakeholder alignment</li>
</ul>
</div>
<div>
<h4 className="text-sm font-medium mb-2">Exit Criteria</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Deliverables complete</li>
<li> Quality checks passed</li>
<li> Ready for next phase</li>
</ul>
</div>
</div>
<div className="flex justify-end mt-4">
<Button variant="ghost" size="sm" onClick={() => setSelectedPhase(null)}>
Close
</Button>
</div>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,80 @@
'use client'
import { motion, AnimatePresence } from 'motion/react'
import { formatDistanceToNow } from 'date-fns'
import { cn } from '@/lib/utils'
import {
CheckCircle2,
AlertCircle,
Info,
Zap,
ArrowRight,
Clock
} from 'lucide-react'
export interface Activity {
id: string
type: 'phase_change' | 'action' | 'notification' | 'error' | 'success'
message: string
timestamp: Date
metadata?: Record<string, unknown>
}
interface ActivityFeedProps {
activities: Activity[]
maxItems?: number
className?: string
}
const typeConfig = {
phase_change: { icon: ArrowRight, color: 'text-blue-500', bg: 'bg-blue-500/10' },
action: { icon: Zap, color: 'text-violet-500', bg: 'bg-violet-500/10' },
notification: { icon: Info, color: 'text-muted-foreground', bg: 'bg-muted' },
error: { icon: AlertCircle, color: 'text-red-500', bg: 'bg-red-500/10' },
success: { icon: CheckCircle2, color: 'text-emerald-500', bg: 'bg-emerald-500/10' },
}
export function ActivityFeed({ activities, maxItems = 10, className }: ActivityFeedProps) {
const displayActivities = activities.slice(0, maxItems)
if (displayActivities.length === 0) {
return (
<div className={cn('flex flex-col items-center justify-center py-8 text-muted-foreground', className)}>
<Clock className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm">No activity yet</p>
</div>
)
}
return (
<div className={cn('space-y-2', className)}>
<AnimatePresence mode="popLayout">
{displayActivities.map((activity, index) => {
const config = typeConfig[activity.type]
const Icon = config.icon
return (
<motion.div
key={activity.id}
initial={{ opacity: 0, x: -20, height: 0 }}
animate={{ opacity: 1, x: 0, height: 'auto' }}
exit={{ opacity: 0, x: 20, height: 0 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
className="flex items-start gap-3 p-3 rounded-lg bg-card border hover:border-foreground/10 transition-colors"
>
<div className={cn('p-1.5 rounded-full shrink-0', config.bg)}>
<Icon className={cn('w-3.5 h-3.5', config.color)} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground">{activity.message}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{formatDistanceToNow(activity.timestamp, { addSuffix: true })}
</p>
</div>
</motion.div>
)
})}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import { motion, useSpring, useTransform } from 'motion/react'
import { useEffect } from 'react'
import { cn } from '@/lib/utils'
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
interface MetricCardProps {
label: string
value: number
format?: 'number' | 'percent' | 'duration'
trend?: 'up' | 'down' | 'neutral'
trendValue?: string
icon?: React.ReactNode
className?: string
}
function AnimatedNumber({ value, format = 'number' }: { value: number; format?: string }) {
const spring = useSpring(0, { stiffness: 100, damping: 30 })
const display = useTransform(spring, (current) => {
if (format === 'percent') return `${Math.round(current)}%`
if (format === 'duration') return `${current.toFixed(1)}s`
return Math.round(current).toLocaleString()
})
useEffect(() => {
spring.set(value)
}, [spring, value])
return <motion.span>{display}</motion.span>
}
export function MetricCard({
label,
value,
format = 'number',
trend,
trendValue,
icon,
className
}: MetricCardProps) {
const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -2, transition: { duration: 0.2 } }}
className={cn(
'p-4 rounded-xl border bg-card hover:border-foreground/10 transition-colors',
className
)}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground">{label}</span>
{icon && <span className="text-muted-foreground">{icon}</span>}
</div>
<div className="flex items-end gap-2">
<span className="text-2xl font-bold">
<AnimatedNumber value={value} format={format} />
</span>
{trend && trendValue && (
<span className={cn(
'flex items-center gap-0.5 text-xs font-medium pb-1',
trend === 'up' && 'text-emerald-500',
trend === 'down' && 'text-red-500',
trend === 'neutral' && 'text-muted-foreground'
)}>
<TrendIcon className="w-3 h-3" />
{trendValue}
</span>
)}
</div>
</motion.div>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'
import { Check, Circle, Loader2, AlertTriangle, X, SkipForward } from 'lucide-react'
export type PhaseStatus = 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'failed' | 'skipped'
interface PhaseIndicatorProps {
phase: string
status: PhaseStatus
label?: string
size?: 'sm' | 'md' | 'lg'
showLabel?: boolean
}
const statusConfig: Record<PhaseStatus, { icon: typeof Circle; color: string; bg: string; ring: string; animate?: boolean }> = {
not_started: { icon: Circle, color: 'text-muted-foreground', bg: 'bg-muted', ring: '' },
in_progress: { icon: Loader2, color: 'text-blue-500', bg: 'bg-blue-500/10', ring: 'ring-2 ring-blue-500/30', animate: true },
completed: { icon: Check, color: 'text-emerald-500', bg: 'bg-emerald-500/10', ring: '' },
blocked: { icon: AlertTriangle, color: 'text-amber-500', bg: 'bg-amber-500/10', ring: 'ring-2 ring-amber-500/30' },
failed: { icon: X, color: 'text-red-500', bg: 'bg-red-500/10', ring: '' },
skipped: { icon: SkipForward, color: 'text-muted-foreground/50', bg: 'bg-muted/50', ring: '' },
}
const sizeConfig = {
sm: { wrapper: 'p-1.5', icon: 'w-3 h-3', text: 'text-xs' },
md: { wrapper: 'p-2', icon: 'w-4 h-4', text: 'text-sm' },
lg: { wrapper: 'p-3', icon: 'w-5 h-5', text: 'text-base' },
}
export function PhaseIndicator({ phase, status, label, size = 'md', showLabel = true }: PhaseIndicatorProps) {
const config = statusConfig[status]
const sizes = sizeConfig[size]
const Icon = config.icon
return (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className="flex items-center gap-2"
>
<motion.div
className={cn('rounded-full transition-all duration-300', sizes.wrapper, config.bg, config.ring)}
animate={status === 'in_progress' ? { scale: [1, 1.05, 1] } : {}}
transition={{ repeat: Infinity, duration: 2 }}
>
<Icon className={cn(sizes.icon, config.color, config.animate && 'animate-spin')} />
</motion.div>
{showLabel && (
<span className={cn('font-medium capitalize', sizes.text, status === 'in_progress' ? 'text-foreground' : 'text-muted-foreground')}>
{label || phase}
</span>
)}
</motion.div>
)
}

View File

@@ -0,0 +1,141 @@
'use client'
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'
import { Check, Circle, Loader2 } from 'lucide-react'
export const PHASES = ['think', 'evaluate', 'ideate', 'plan', 'create', 'deploy', 'analyze', 'enhance'] as const
export type Phase = typeof PHASES[number]
interface ProgressTimelineProps {
currentPhase: Phase
completedPhases: Phase[]
className?: string
variant?: 'horizontal' | 'vertical'
showLabels?: boolean
}
export function ProgressTimeline({
currentPhase,
completedPhases,
className,
variant = 'horizontal',
showLabels = true
}: ProgressTimelineProps) {
const progress = (completedPhases.length / PHASES.length) * 100
if (variant === 'vertical') {
return (
<div className={cn('flex flex-col gap-1', className)}>
{PHASES.map((phase, index) => {
const isCompleted = completedPhases.includes(phase)
const isCurrent = phase === currentPhase
const isPast = PHASES.indexOf(phase) < PHASES.indexOf(currentPhase)
return (
<motion.div
key={phase}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className="flex items-center gap-3"
>
<div className="flex flex-col items-center">
<motion.div
className={cn(
'w-8 h-8 rounded-full flex items-center justify-center transition-all duration-300',
isCompleted && 'bg-emerald-500 text-white',
isCurrent && 'bg-blue-500 text-white ring-4 ring-blue-500/20',
!isCompleted && !isCurrent && 'bg-muted text-muted-foreground'
)}
animate={isCurrent ? { scale: [1, 1.1, 1] } : {}}
transition={{ repeat: Infinity, duration: 2 }}
>
{isCompleted ? (
<Check className="w-4 h-4" />
) : isCurrent ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Circle className="w-4 h-4" />
)}
</motion.div>
{index < PHASES.length - 1 && (
<div className={cn(
'w-0.5 h-6 transition-colors duration-300',
isPast || isCompleted ? 'bg-emerald-500' : 'bg-muted'
)} />
)}
</div>
{showLabels && (
<span className={cn(
'text-sm capitalize transition-colors',
isCurrent && 'font-semibold text-foreground',
isCompleted && 'text-emerald-600',
!isCurrent && !isCompleted && 'text-muted-foreground'
)}>
{phase}
</span>
)}
</motion.div>
)
})}
</div>
)
}
return (
<div className={cn('w-full', className)}>
<div className="relative h-2 bg-muted rounded-full overflow-hidden mb-4">
<motion.div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 via-violet-500 to-emerald-500"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
<motion.div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500/50 via-violet-500/50 to-emerald-500/50 blur-sm"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
</div>
<div className="flex justify-between">
{PHASES.map((phase, index) => {
const isCompleted = completedPhases.includes(phase)
const isCurrent = phase === currentPhase
return (
<motion.div
key={phase}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="flex flex-col items-center"
>
<motion.div
className={cn(
'w-3 h-3 rounded-full transition-all duration-300 mb-1',
isCompleted && 'bg-emerald-500',
isCurrent && 'bg-blue-500 ring-4 ring-blue-500/20',
!isCompleted && !isCurrent && 'bg-muted-foreground/30'
)}
animate={isCurrent ? { scale: [1, 1.3, 1] } : {}}
transition={{ repeat: Infinity, duration: 1.5 }}
/>
{showLabels && (
<span className={cn(
'text-xs capitalize hidden sm:block',
isCurrent && 'font-semibold text-foreground',
!isCurrent && 'text-muted-foreground'
)}>
{phase}
</span>
)}
</motion.div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { cn } from '@/lib/utils'
const variants = {
default: 'bg-muted text-muted-foreground border-muted',
success: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
warning: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
error: 'bg-red-500/10 text-red-600 border-red-500/20',
info: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
purple: 'bg-violet-500/10 text-violet-600 border-violet-500/20',
}
interface StatusBadgeProps {
variant?: keyof typeof variants
children: React.ReactNode
pulse?: boolean
className?: string
}
export function StatusBadge({ variant = 'default', children, pulse, className }: StatusBadgeProps) {
const pulseColor = {
default: 'bg-muted-foreground',
success: 'bg-emerald-500',
warning: 'bg-amber-500',
error: 'bg-red-500',
info: 'bg-blue-500',
purple: 'bg-violet-500',
}
return (
<span className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border',
variants[variant],
className
)}>
{pulse && (
<span className="relative flex h-2 w-2">
<span className={cn(
'animate-ping absolute inline-flex h-full w-full rounded-full opacity-75',
pulseColor[variant]
)} />
<span className={cn(
'relative inline-flex rounded-full h-2 w-2',
pulseColor[variant]
)} />
</span>
)}
{children}
</span>
)
}