Generated by Spark: Ok implement new features from ROADMAP, dont edit roadmap file

This commit is contained in:
2026-01-22 14:28:10 +00:00
committed by GitHub
parent f5efbef9bb
commit c6241732ef
7 changed files with 2051 additions and 11 deletions

View File

@@ -1,4 +1,4 @@
{
"templateVersion": 0,
"dbType": null
"dbType": "kv"
}

View File

@@ -15,12 +15,17 @@ import {
GridFour,
Circle,
House,
CaretDown
CaretDown,
CurrencyDollar,
CheckCircle,
ChartLineUp,
GitBranch
} from '@phosphor-icons/react'
import { cn } from '@/lib/utils'
import StrategyCards from './components/StrategyCards'
import Workbench from './components/Workbench'
import Portfolios from './components/Portfolios'
import PortfolioAnalysis from './components/PortfolioAnalysis'
import Dashboard from './components/Dashboard'
import Roadmap from './components/Roadmap'
import ProductRoadmap from './components/ProductRoadmap'
@@ -30,6 +35,9 @@ import StrategyComparison from './components/StrategyComparison'
import StrategyTraceability from './components/StrategyTraceability'
import XMatrix from './components/XMatrix'
import BowlingChart from './components/BowlingChart'
import OKRManagement from './components/OKRManagement'
import FinancialTracking from './components/FinancialTracking'
import ExecutiveDashboard from './components/ExecutiveDashboard'
import type { StrategyCard, Initiative } from './types'
type NavigationItem = {
@@ -61,7 +69,9 @@ const navigationSections: NavigationSection[] = [
items: [
{ id: 'workbench', label: 'Workbench', icon: ChartBar, component: Workbench },
{ id: 'tracker', label: 'Initiative Tracker', icon: TrendUp, component: InitiativeTracker },
{ id: 'okr', label: 'OKR Management', icon: CheckCircle, component: OKRManagement },
{ id: 'portfolios', label: 'Portfolios', icon: FolderOpen, component: Portfolios },
{ id: 'portfolio-analysis', label: 'Portfolio Analysis', icon: GitBranch, component: PortfolioAnalysis },
]
},
{
@@ -84,8 +94,10 @@ const navigationSections: NavigationSection[] = [
id: 'reporting',
label: 'Reporting',
items: [
{ id: 'dashboard', label: 'Executive Dashboard', icon: Target, component: Dashboard },
{ id: 'executive-dashboard', label: 'Executive Dashboard', icon: ChartLineUp, component: ExecutiveDashboard },
{ id: 'dashboard', label: 'Performance Dashboard', icon: Target, component: Dashboard },
{ id: 'kpi', label: 'KPI Scorecard', icon: ChartLine, component: KPIDashboard },
{ id: 'financial', label: 'Financial Tracking', icon: CurrencyDollar, component: FinancialTracking },
]
}
]
@@ -281,13 +293,17 @@ function getModuleDescription(moduleId: string): string {
'traceability': 'Map relationships from goals to initiatives',
'workbench': 'Execute and track strategic initiatives',
'tracker': 'Monitor initiative progress with real-time status',
'okr': 'Define and track Objectives and Key Results',
'portfolios': 'Organize initiatives into strategic portfolios',
'portfolio-analysis': 'Strategic alignment, capacity & dependency analysis',
'x-matrix': 'Align objectives using Hoshin Kanri methodology',
'bowling': 'Track monthly progress with visual indicators',
'roadmap': 'Visualize strategic timeline and milestones',
'product-roadmap': 'Plan and track product development initiatives',
'dashboard': 'Executive-level view of strategic performance',
'executive-dashboard': 'Executive-level strategic performance overview',
'dashboard': 'Real-time performance metrics and insights',
'kpi': 'Monitor key performance indicators and metrics',
'financial': 'Track financial outcomes and value realization',
}
return descriptions[moduleId] || 'Manage your strategic initiatives'
}

View File

@@ -0,0 +1,388 @@
import { useKV } from '@github/spark/hooks'
import { useMemo } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Separator } from '@/components/ui/separator'
import {
Target,
TrendUp,
TrendDown,
CheckCircle,
Warning,
CurrencyDollar,
ChartBar,
Users,
Lightning,
ArrowUp,
ArrowDown
} from '@phosphor-icons/react'
import type { Initiative, StrategyCard } from '@/types'
interface FinancialOutcome {
plannedAmount: number
actualAmount: number
status: string
}
export default function ExecutiveDashboard() {
const [strategyCards] = useKV<StrategyCard[]>('strategy-cards', [])
const [initiatives] = useKV<Initiative[]>('initiatives', [])
const [financialOutcomes] = useKV<FinancialOutcome[]>('financial-outcomes', [])
const [objectives] = useKV<any[]>('okr-objectives', [])
const stats = useMemo(() => {
const totalInitiatives = initiatives?.length || 0
const completedInitiatives = initiatives?.filter(i => i.status === 'completed').length || 0
const atRiskInitiatives = initiatives?.filter(i => i.status === 'at-risk' || i.status === 'blocked').length || 0
const onTrackInitiatives = initiatives?.filter(i => i.status === 'on-track').length || 0
const totalPlanned = financialOutcomes?.reduce((sum, o) => sum + o.plannedAmount, 0) || 0
const totalRealized = financialOutcomes?.filter(o => o.status === 'realized' || o.status === 'validated')
.reduce((sum, o) => sum + o.actualAmount, 0) || 0
const avgProgress = initiatives?.length ?
initiatives.reduce((sum, i) => sum + i.progress, 0) / initiatives.length : 0
const totalObjectives = objectives?.length || 0
const achievedObjectives = objectives?.filter(o => o.status === 'achieved').length || 0
return {
strategies: strategyCards?.length || 0,
initiatives: totalInitiatives,
completedInitiatives,
atRiskInitiatives,
onTrackInitiatives,
objectives: totalObjectives,
achievedObjectives,
avgProgress: Math.round(avgProgress),
completionRate: totalInitiatives > 0 ? Math.round((completedInitiatives / totalInitiatives) * 100) : 0,
financialPlanned: totalPlanned,
financialRealized: totalRealized,
financialRealizationRate: totalPlanned > 0 ? Math.round((totalRealized / totalPlanned) * 100) : 0
}
}, [strategyCards, initiatives, financialOutcomes, objectives])
const portfolioBreakdown = useMemo(() => {
const portfolios = initiatives?.reduce((acc, init) => {
const key = init.portfolio
if (!acc[key]) {
acc[key] = {
total: 0,
completed: 0,
atRisk: 0,
onTrack: 0,
budget: 0,
progress: []
}
}
acc[key].total++
if (init.status === 'completed') acc[key].completed++
if (init.status === 'at-risk' || init.status === 'blocked') acc[key].atRisk++
if (init.status === 'on-track') acc[key].onTrack++
acc[key].budget += init.budget || 0
acc[key].progress.push(init.progress)
return acc
}, {} as Record<string, any>) || {}
return Object.entries(portfolios).map(([name, data]) => ({
name: name.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
...data,
avgProgress: data.progress.length > 0 ? Math.round(data.progress.reduce((a: number, b: number) => a + b, 0) / data.progress.length) : 0
}))
}, [initiatives])
const recentActivity = useMemo(() => {
const items: any[] = []
initiatives?.slice(-5).reverse().forEach(init => {
items.push({
type: 'initiative',
title: init.title,
status: init.status,
date: init.startDate,
priority: init.priority
})
})
return items.slice(0, 8)
}, [initiatives])
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount)
}
const portfolioColors: Record<string, string> = {
'operational-excellence': 'bg-blue-500',
'ma': 'bg-purple-500',
'financial-transformation': 'bg-green-500',
'esg': 'bg-teal-500',
'innovation': 'bg-orange-500'
}
return (
<div className="space-y-6">
<div>
<h2 className="text-3xl font-bold tracking-tight">Executive Dashboard</h2>
<p className="text-muted-foreground mt-2">
High-level view of strategic performance and progress
</p>
</div>
<div className="grid grid-cols-6 gap-4">
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium text-muted-foreground">Strategy Cards</CardTitle>
<Target size={20} className="text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stats.strategies}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium text-muted-foreground">Active Initiatives</CardTitle>
<Lightning size={20} className="text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stats.initiatives}</div>
<p className="text-xs text-muted-foreground mt-1">
{stats.completedInitiatives} completed
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium text-muted-foreground">OKRs</CardTitle>
<CheckCircle size={20} className="text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stats.objectives}</div>
<p className="text-xs text-muted-foreground mt-1">
{stats.achievedObjectives} achieved
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium text-muted-foreground">Avg Progress</CardTitle>
<ChartBar size={20} className="text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stats.avgProgress}%</div>
<Progress value={stats.avgProgress} className="h-1.5 mt-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium text-muted-foreground">On Track</CardTitle>
<TrendUp size={20} className="text-success" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-success">{stats.onTrackInitiatives}</div>
<p className="text-xs text-muted-foreground mt-1">initiatives</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium text-muted-foreground">At Risk</CardTitle>
<Warning size={20} className="text-at-risk" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-at-risk">{stats.atRiskInitiatives}</div>
<p className="text-xs text-muted-foreground mt-1">need attention</p>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-3 gap-4">
<Card className="col-span-2">
<CardHeader>
<CardTitle>Financial Value Realization</CardTitle>
<CardDescription>Planned vs actual financial outcomes</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">Total Planned Value</p>
<p className="text-2xl font-bold">{formatCurrency(stats.financialPlanned)}</p>
</div>
<div className="text-right">
<p className="text-sm font-medium text-muted-foreground mb-1">Realized Value</p>
<p className="text-2xl font-bold text-success">{formatCurrency(stats.financialRealized)}</p>
</div>
<div className="text-right">
<p className="text-sm font-medium text-muted-foreground mb-1">Realization Rate</p>
<p className="text-2xl font-bold text-accent">{stats.financialRealizationRate}%</p>
</div>
</div>
<Progress value={stats.financialRealizationRate} className="h-3" />
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CurrencyDollar size={16} />
<span>
{formatCurrency(stats.financialPlanned - stats.financialRealized)} remaining to realize
</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Initiative Health</CardTitle>
<CardDescription>Status distribution</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-success"></div>
<span className="text-sm">On Track</span>
</div>
<span className="font-semibold">{stats.onTrackInitiatives}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-at-risk"></div>
<span className="text-sm">At Risk</span>
</div>
<span className="font-semibold">{stats.atRiskInitiatives}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-accent"></div>
<span className="text-sm">Completed</span>
</div>
<span className="font-semibold">{stats.completedInitiatives}</span>
</div>
<Separator className="my-3" />
<div className="flex items-center justify-between text-sm font-semibold">
<span>Completion Rate</span>
<span className="text-accent">{stats.completionRate}%</span>
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Portfolio Performance</CardTitle>
<CardDescription>Progress and health by strategic portfolio</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{portfolioBreakdown.length === 0 ? (
<p className="text-center text-muted-foreground py-8">No portfolio data available</p>
) : (
portfolioBreakdown.map((portfolio) => (
<div key={portfolio.name} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${portfolioColors[portfolio.name.toLowerCase().replace(/ /g, '-')] || 'bg-gray-500'}`}></div>
<h4 className="font-semibold">{portfolio.name}</h4>
<Badge variant="secondary">{portfolio.total} initiatives</Badge>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="text-right">
<p className="text-muted-foreground">Budget</p>
<p className="font-semibold">{formatCurrency(portfolio.budget)}</p>
</div>
<div className="text-right">
<p className="text-muted-foreground">Progress</p>
<p className="font-semibold">{portfolio.avgProgress}%</p>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-3 mb-3 text-sm">
<div className="flex items-center gap-2">
<CheckCircle size={16} className="text-success" weight="fill" />
<span>{portfolio.onTrack} on track</span>
</div>
<div className="flex items-center gap-2">
<Warning size={16} className="text-at-risk" weight="fill" />
<span>{portfolio.atRisk} at risk</span>
</div>
<div className="flex items-center gap-2">
<Target size={16} className="text-accent" weight="fill" />
<span>{portfolio.completed} completed</span>
</div>
</div>
<Progress value={portfolio.avgProgress} className="h-2" />
</div>
))
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>Latest updates across initiatives</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{recentActivity.length === 0 ? (
<p className="text-center text-muted-foreground py-8">No recent activity</p>
) : (
recentActivity.map((item, idx) => (
<div key={idx} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${
item.status === 'completed' ? 'bg-success' :
item.status === 'on-track' ? 'bg-accent' :
item.status === 'at-risk' || item.status === 'blocked' ? 'bg-at-risk' :
'bg-muted'
}`}></div>
<div>
<p className="font-medium text-sm">{item.title}</p>
<p className="text-xs text-muted-foreground">
{item.type === 'initiative' ? 'Initiative' : 'Activity'}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant={
item.priority === 'critical' ? 'destructive' :
item.priority === 'high' ? 'default' : 'secondary'
}>
{item.priority}
</Badge>
<span className="text-xs text-muted-foreground">
{new Date(item.date).toLocaleDateString()}
</span>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,492 @@
import { useKV } from '@github/spark/hooks'
import { useState, useMemo } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { Plus, CurrencyDollar, TrendUp, TrendDown, ChartLine, Target, Calendar, CheckCircle } from '@phosphor-icons/react'
import { toast } from 'sonner'
import type { Initiative } from '@/types'
interface FinancialOutcome {
id: string
initiativeId: string
initiativeTitle: string
category: 'cost-savings' | 'revenue-increase' | 'cost-avoidance' | 'efficiency-gain' | 'other'
description: string
plannedAmount: number
actualAmount: number
currency: string
realizationDate: string
status: 'projected' | 'realized' | 'validated'
validatedBy?: string
notes?: string
createdAt: string
updatedAt: string
}
const categoryConfig = {
'cost-savings': { label: 'Cost Savings', color: 'bg-green-500', icon: TrendDown },
'revenue-increase': { label: 'Revenue Increase', color: 'bg-blue-500', icon: TrendUp },
'cost-avoidance': { label: 'Cost Avoidance', color: 'bg-purple-500', icon: Target },
'efficiency-gain': { label: 'Efficiency Gain', color: 'bg-orange-500', icon: ChartLine },
'other': { label: 'Other', color: 'bg-gray-500', icon: CurrencyDollar }
}
export default function FinancialTracking() {
const [outcomes, setOutcomes] = useKV<FinancialOutcome[]>('financial-outcomes', [])
const [initiatives] = useKV<Initiative[]>('initiatives', [])
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [selectedCategory, setSelectedCategory] = useState<string>('all')
const [selectedStatus, setSelectedStatus] = useState<string>('all')
const [newOutcome, setNewOutcome] = useState({
initiativeId: '',
category: 'cost-savings' as const,
description: '',
plannedAmount: 0,
currency: 'USD',
realizationDate: '',
notes: ''
})
const addOutcome = () => {
if (!newOutcome.initiativeId || !newOutcome.description || !newOutcome.plannedAmount) {
toast.error('Please fill in required fields')
return
}
const initiative = initiatives?.find(i => i.id === newOutcome.initiativeId)
if (!initiative) {
toast.error('Initiative not found')
return
}
const outcome: FinancialOutcome = {
id: `outcome-${Date.now()}`,
initiativeId: newOutcome.initiativeId,
initiativeTitle: initiative.title,
category: newOutcome.category,
description: newOutcome.description,
plannedAmount: newOutcome.plannedAmount,
actualAmount: 0,
currency: newOutcome.currency,
realizationDate: newOutcome.realizationDate,
status: 'projected',
notes: newOutcome.notes,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
setOutcomes((current) => [...(current || []), outcome])
setIsAddDialogOpen(false)
setNewOutcome({
initiativeId: '',
category: 'cost-savings',
description: '',
plannedAmount: 0,
currency: 'USD',
realizationDate: '',
notes: ''
})
toast.success('Financial outcome tracked successfully')
}
const updateOutcomeStatus = (id: string, status: FinancialOutcome['status'], actualAmount?: number) => {
setOutcomes((current) =>
(current || []).map(o =>
o.id === id
? {
...o,
status,
actualAmount: actualAmount !== undefined ? actualAmount : o.actualAmount,
updatedAt: new Date().toISOString()
}
: o
)
)
toast.success('Status updated')
}
const filteredOutcomes = (outcomes || []).filter(o => {
if (selectedCategory !== 'all' && o.category !== selectedCategory) return false
if (selectedStatus !== 'all' && o.status !== selectedStatus) return false
return true
})
const stats = useMemo(() => {
const all = outcomes || []
return {
totalPlanned: all.reduce((sum, o) => sum + o.plannedAmount, 0),
totalRealized: all.filter(o => o.status === 'realized' || o.status === 'validated').reduce((sum, o) => sum + o.actualAmount, 0),
totalProjected: all.filter(o => o.status === 'projected').reduce((sum, o) => sum + o.plannedAmount, 0),
realizationRate: all.length > 0 ? (all.filter(o => o.status === 'realized' || o.status === 'validated').length / all.length) * 100 : 0
}
}, [outcomes])
const categoryBreakdown = useMemo(() => {
return Object.keys(categoryConfig).map(cat => {
const categoryOutcomes = filteredOutcomes.filter(o => o.category === cat)
return {
category: cat,
planned: categoryOutcomes.reduce((sum, o) => sum + o.plannedAmount, 0),
realized: categoryOutcomes.filter(o => o.status === 'realized' || o.status === 'validated').reduce((sum, o) => sum + o.actualAmount, 0),
count: categoryOutcomes.length
}
}).filter(c => c.count > 0)
}, [filteredOutcomes])
const formatCurrency = (amount: number, currency: string = 'USD') => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount)
}
return (
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Financial Outcome Tracking</h2>
<p className="text-muted-foreground mt-2">
Link strategic initiatives to measurable financial results
</p>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus size={16} weight="bold" />
Track Outcome
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Track Financial Outcome</DialogTitle>
<DialogDescription>
Link a financial outcome to a strategic initiative
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="initiative">Initiative *</Label>
<Select
value={newOutcome.initiativeId}
onValueChange={(value) => setNewOutcome({ ...newOutcome, initiativeId: value })}
>
<SelectTrigger id="initiative">
<SelectValue placeholder="Select initiative" />
</SelectTrigger>
<SelectContent>
{(initiatives || []).map(init => (
<SelectItem key={init.id} value={init.id}>
{init.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="category">Outcome Category *</Label>
<Select
value={newOutcome.category}
onValueChange={(value: any) => setNewOutcome({ ...newOutcome, category: value })}
>
<SelectTrigger id="category">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(categoryConfig).map(([key, config]) => (
<SelectItem key={key} value={key}>
{config.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description *</Label>
<Input
id="description"
value={newOutcome.description}
onChange={(e) => setNewOutcome({ ...newOutcome, description: e.target.value })}
placeholder="e.g., Reduced operational costs through automation"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="grid gap-2 col-span-2">
<Label htmlFor="planned-amount">Planned Amount *</Label>
<Input
id="planned-amount"
type="number"
value={newOutcome.plannedAmount}
onChange={(e) => setNewOutcome({ ...newOutcome, plannedAmount: parseFloat(e.target.value) })}
placeholder="0"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="currency">Currency</Label>
<Select
value={newOutcome.currency}
onValueChange={(value) => setNewOutcome({ ...newOutcome, currency: value })}
>
<SelectTrigger id="currency">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="GBP">GBP</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="realization-date">Expected Realization Date</Label>
<Input
id="realization-date"
type="date"
value={newOutcome.realizationDate}
onChange={(e) => setNewOutcome({ ...newOutcome, realizationDate: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notes</Label>
<Input
id="notes"
value={newOutcome.notes}
onChange={(e) => setNewOutcome({ ...newOutcome, notes: e.target.value })}
placeholder="Additional context..."
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>
Cancel
</Button>
<Button onClick={addOutcome}>Track Outcome</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="grid grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Planned</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatCurrency(stats.totalPlanned)}</div>
<p className="text-xs text-muted-foreground mt-1">Across all initiatives</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Realized</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-success">{formatCurrency(stats.totalRealized)}</div>
<p className="text-xs text-muted-foreground mt-1">Validated outcomes</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Projected Value</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-accent">{formatCurrency(stats.totalProjected)}</div>
<p className="text-xs text-muted-foreground mt-1">Pending realization</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Realization Rate</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{Math.round(stats.realizationRate)}%</div>
<Progress value={stats.realizationRate} className="h-1.5 mt-2" />
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Financial Impact by Category</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{categoryBreakdown.map(({ category, planned, realized, count }) => {
const config = categoryConfig[category as keyof typeof categoryConfig]
const Icon = config.icon
const realizationPct = planned > 0 ? (realized / planned) * 100 : 0
return (
<div key={category}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className={`${config.color} p-2 rounded-md`}>
<Icon size={16} weight="bold" className="text-white" />
</div>
<div>
<p className="font-semibold text-sm">{config.label}</p>
<p className="text-xs text-muted-foreground">{count} outcome{count !== 1 ? 's' : ''}</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-semibold">{formatCurrency(realized)} / {formatCurrency(planned)}</p>
<p className="text-xs text-muted-foreground">{Math.round(realizationPct)}% realized</p>
</div>
</div>
<Progress value={realizationPct} className="h-2" />
</div>
)
})}
</div>
</CardContent>
</Card>
<div className="flex items-center gap-3">
<Label className="text-sm font-medium">Filters:</Label>
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{Object.entries(categoryConfig).map(([key, config]) => (
<SelectItem key={key} value={key}>
{config.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="projected">Projected</SelectItem>
<SelectItem value="realized">Realized</SelectItem>
<SelectItem value="validated">Validated</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
{filteredOutcomes.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<CurrencyDollar size={48} className="text-muted-foreground mb-4" />
<p className="text-muted-foreground text-center">
No financial outcomes tracked yet. Start linking initiatives to financial results.
</p>
</CardContent>
</Card>
) : (
filteredOutcomes.map((outcome) => {
const config = categoryConfig[outcome.category]
const Icon = config.icon
const variance = outcome.actualAmount - outcome.plannedAmount
const variancePct = outcome.plannedAmount > 0 ? (variance / outcome.plannedAmount) * 100 : 0
return (
<Card key={outcome.id}>
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<div className={`${config.color} p-2 rounded-md`}>
<Icon size={20} weight="bold" className="text-white" />
</div>
<div>
<h4 className="font-semibold">{outcome.description}</h4>
<p className="text-sm text-muted-foreground">{outcome.initiativeTitle}</p>
</div>
</div>
<div className="flex items-center gap-4 mt-3 text-sm">
<Badge variant={
outcome.status === 'validated' ? 'default' :
outcome.status === 'realized' ? 'secondary' : 'outline'
}>
{outcome.status}
</Badge>
<Badge variant="outline">{config.label}</Badge>
{outcome.realizationDate && (
<div className="flex items-center gap-1 text-muted-foreground">
<Calendar size={14} />
<span>{new Date(outcome.realizationDate).toLocaleDateString()}</span>
</div>
)}
</div>
{outcome.notes && (
<p className="text-sm text-muted-foreground mt-2 italic">{outcome.notes}</p>
)}
</div>
<div className="text-right">
<div className="space-y-1 mb-3">
<p className="text-xs text-muted-foreground">Planned</p>
<p className="text-xl font-bold">{formatCurrency(outcome.plannedAmount, outcome.currency)}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Actual</p>
<p className="text-xl font-bold text-success">{formatCurrency(outcome.actualAmount, outcome.currency)}</p>
</div>
{outcome.status === 'realized' || outcome.status === 'validated' ? (
<div className={`text-xs mt-2 ${variance >= 0 ? 'text-success' : 'text-destructive'}`}>
{variance >= 0 ? '+' : ''}{formatCurrency(variance, outcome.currency)} ({variancePct >= 0 ? '+' : ''}{Math.round(variancePct)}%)
</div>
) : null}
</div>
</div>
{outcome.status === 'projected' && (
<div className="mt-4 pt-4 border-t flex items-center gap-2">
<Input
type="number"
placeholder="Enter actual amount"
className="h-9"
id={`actual-${outcome.id}`}
/>
<Button
size="sm"
onClick={() => {
const input = document.getElementById(`actual-${outcome.id}`) as HTMLInputElement
const actualAmount = parseFloat(input.value)
if (!isNaN(actualAmount)) {
updateOutcomeStatus(outcome.id, 'realized', actualAmount)
}
}}
>
Mark Realized
</Button>
</div>
)}
{outcome.status === 'realized' && (
<div className="mt-4 pt-4 border-t">
<Button
size="sm"
variant="outline"
className="gap-2"
onClick={() => updateOutcomeStatus(outcome.id, 'validated')}
>
<CheckCircle size={16} weight="fill" />
Validate Outcome
</Button>
</div>
)}
</CardContent>
</Card>
)
})
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,610 @@
import { useKV } from '@github/spark/hooks'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Separator } from '@/components/ui/separator'
import { Plus, Target, CheckCircle, TrendUp, TrendDown, User, Calendar, ArrowRight, Trash } from '@phosphor-icons/react'
import { toast } from 'sonner'
interface KeyResult {
id: string
description: string
startValue: number
currentValue: number
targetValue: number
unit: string
progress: number
status: 'on-track' | 'at-risk' | 'behind' | 'achieved'
lastUpdated: string
}
interface Objective {
id: string
title: string
description: string
owner: string
category: 'company' | 'team' | 'individual'
timeframe: 'quarterly' | 'annual'
quarter?: string
year: string
status: 'active' | 'achieved' | 'at-risk' | 'abandoned'
keyResults: KeyResult[]
linkedInitiatives: string[]
createdAt: string
updatedAt: string
}
export default function OKRManagement() {
const [objectives, setObjectives] = useKV<Objective[]>('okr-objectives', [])
const [initiatives] = useKV<any[]>('initiatives', [])
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isAddKRDialogOpen, setIsAddKRDialogOpen] = useState(false)
const [selectedObjective, setSelectedObjective] = useState<Objective | null>(null)
const [selectedCategory, setSelectedCategory] = useState<'all' | 'company' | 'team' | 'individual'>('all')
const [selectedTimeframe, setSelectedTimeframe] = useState<'all' | 'quarterly' | 'annual'>('all')
const [newObjective, setNewObjective] = useState({
title: '',
description: '',
owner: '',
category: 'team' as const,
timeframe: 'quarterly' as const,
quarter: 'Q1',
year: new Date().getFullYear().toString()
})
const [newKeyResult, setNewKeyResult] = useState({
description: '',
startValue: 0,
targetValue: 100,
unit: '%'
})
const addObjective = () => {
if (!newObjective.title || !newObjective.owner) {
toast.error('Please fill in required fields')
return
}
const objective: Objective = {
id: `obj-${Date.now()}`,
title: newObjective.title,
description: newObjective.description,
owner: newObjective.owner,
category: newObjective.category,
timeframe: newObjective.timeframe,
quarter: newObjective.timeframe === 'quarterly' ? newObjective.quarter : undefined,
year: newObjective.year,
status: 'active',
keyResults: [],
linkedInitiatives: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
setObjectives((current) => [...(current || []), objective])
setIsAddDialogOpen(false)
setNewObjective({
title: '',
description: '',
owner: '',
category: 'team',
timeframe: 'quarterly',
quarter: 'Q1',
year: new Date().getFullYear().toString()
})
toast.success('Objective created successfully')
}
const addKeyResult = () => {
if (!selectedObjective || !newKeyResult.description) {
toast.error('Please fill in required fields')
return
}
const keyResult: KeyResult = {
id: `kr-${Date.now()}`,
description: newKeyResult.description,
startValue: newKeyResult.startValue,
currentValue: newKeyResult.startValue,
targetValue: newKeyResult.targetValue,
unit: newKeyResult.unit,
progress: 0,
status: 'on-track',
lastUpdated: new Date().toISOString()
}
setObjectives((current) =>
(current || []).map(obj =>
obj.id === selectedObjective.id
? { ...obj, keyResults: [...obj.keyResults, keyResult], updatedAt: new Date().toISOString() }
: obj
)
)
setIsAddKRDialogOpen(false)
setNewKeyResult({
description: '',
startValue: 0,
targetValue: 100,
unit: '%'
})
toast.success('Key Result added successfully')
}
const updateKeyResultProgress = (objectiveId: string, krId: string, newValue: number) => {
setObjectives((current) =>
(current || []).map(obj => {
if (obj.id === objectiveId) {
const updatedKRs = obj.keyResults.map(kr => {
if (kr.id === krId) {
const range = kr.targetValue - kr.startValue
const progress = Math.min(100, Math.max(0, ((newValue - kr.startValue) / range) * 100))
let status: KeyResult['status'] = 'on-track'
if (progress >= 100) status = 'achieved'
else if (progress >= 70) status = 'on-track'
else if (progress >= 40) status = 'at-risk'
else status = 'behind'
return {
...kr,
currentValue: newValue,
progress,
status,
lastUpdated: new Date().toISOString()
}
}
return kr
})
const avgProgress = updatedKRs.reduce((sum, kr) => sum + kr.progress, 0) / updatedKRs.length
let objStatus: Objective['status'] = 'active'
if (avgProgress >= 100) objStatus = 'achieved'
else if (avgProgress < 50) objStatus = 'at-risk'
return { ...obj, keyResults: updatedKRs, status: objStatus, updatedAt: new Date().toISOString() }
}
return obj
})
)
toast.success('Progress updated')
}
const deleteObjective = (id: string) => {
setObjectives((current) => (current || []).filter(obj => obj.id !== id))
toast.success('Objective deleted')
}
const deleteKeyResult = (objectiveId: string, krId: string) => {
setObjectives((current) =>
(current || []).map(obj =>
obj.id === objectiveId
? { ...obj, keyResults: obj.keyResults.filter(kr => kr.id !== krId) }
: obj
)
)
toast.success('Key Result deleted')
}
const filteredObjectives = (objectives || []).filter(obj => {
if (selectedCategory !== 'all' && obj.category !== selectedCategory) return false
if (selectedTimeframe !== 'all' && obj.timeframe !== selectedTimeframe) return false
return true
})
const calculateObjectiveProgress = (obj: Objective) => {
if (obj.keyResults.length === 0) return 0
return obj.keyResults.reduce((sum, kr) => sum + kr.progress, 0) / obj.keyResults.length
}
const getStatusColor = (status: string) => {
switch (status) {
case 'achieved': return 'bg-success text-white'
case 'on-track': return 'bg-success text-white'
case 'at-risk': return 'bg-at-risk text-white'
case 'behind': return 'bg-destructive text-white'
case 'abandoned': return 'bg-muted text-muted-foreground'
default: return 'bg-secondary'
}
}
const stats = {
total: filteredObjectives.length,
achieved: filteredObjectives.filter(o => o.status === 'achieved').length,
active: filteredObjectives.filter(o => o.status === 'active').length,
atRisk: filteredObjectives.filter(o => o.status === 'at-risk').length
}
return (
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">OKR Management</h2>
<p className="text-muted-foreground mt-2">
Define and track Objectives and Key Results across the organization
</p>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus size={16} weight="bold" />
New Objective
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Create New Objective</DialogTitle>
<DialogDescription>
Define a new objective. You'll add key results in the next step.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="title">Objective Title *</Label>
<Input
id="title"
value={newObjective.title}
onChange={(e) => setNewObjective({ ...newObjective, title: e.target.value })}
placeholder="e.g., Become the market leader in customer satisfaction"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={newObjective.description}
onChange={(e) => setNewObjective({ ...newObjective, description: e.target.value })}
placeholder="Describe the objective and its strategic importance..."
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="category">Category *</Label>
<Select
value={newObjective.category}
onValueChange={(value: any) => setNewObjective({ ...newObjective, category: value })}
>
<SelectTrigger id="category">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="company">Company</SelectItem>
<SelectItem value="team">Team</SelectItem>
<SelectItem value="individual">Individual</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="owner">Owner *</Label>
<Input
id="owner"
value={newObjective.owner}
onChange={(e) => setNewObjective({ ...newObjective, owner: e.target.value })}
placeholder="Owner name"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="grid gap-2">
<Label htmlFor="timeframe">Timeframe *</Label>
<Select
value={newObjective.timeframe}
onValueChange={(value: any) => setNewObjective({ ...newObjective, timeframe: value })}
>
<SelectTrigger id="timeframe">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="annual">Annual</SelectItem>
</SelectContent>
</Select>
</div>
{newObjective.timeframe === 'quarterly' && (
<div className="grid gap-2">
<Label htmlFor="quarter">Quarter</Label>
<Select
value={newObjective.quarter}
onValueChange={(value) => setNewObjective({ ...newObjective, quarter: value })}
>
<SelectTrigger id="quarter">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Q1">Q1</SelectItem>
<SelectItem value="Q2">Q2</SelectItem>
<SelectItem value="Q3">Q3</SelectItem>
<SelectItem value="Q4">Q4</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="year">Year</Label>
<Input
id="year"
type="number"
value={newObjective.year}
onChange={(e) => setNewObjective({ ...newObjective, year: e.target.value })}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>
Cancel
</Button>
<Button onClick={addObjective}>Create Objective</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="grid grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Objectives</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stats.total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Achieved</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-success">{stats.achieved}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Active</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-primary">{stats.active}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">At Risk</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-at-risk">{stats.atRisk}</div>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-3">
<Label className="text-sm font-medium">Filters:</Label>
<Select value={selectedCategory} onValueChange={(value: any) => setSelectedCategory(value)}>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="company">Company</SelectItem>
<SelectItem value="team">Team</SelectItem>
<SelectItem value="individual">Individual</SelectItem>
</SelectContent>
</Select>
<Select value={selectedTimeframe} onValueChange={(value: any) => setSelectedTimeframe(value)}>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Timeframes</SelectItem>
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="annual">Annual</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-4">
{filteredObjectives.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Target size={48} className="text-muted-foreground mb-4" />
<p className="text-muted-foreground text-center">
No objectives yet. Create your first objective to get started.
</p>
</CardContent>
</Card>
) : (
filteredObjectives.map((objective) => {
const progress = calculateObjectiveProgress(objective)
return (
<Card key={objective.id} className="overflow-hidden">
<CardHeader className="bg-muted/50">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<CardTitle className="text-xl">{objective.title}</CardTitle>
<Badge className={getStatusColor(objective.status)}>
{objective.status}
</Badge>
<Badge variant="outline">
{objective.category}
</Badge>
<Badge variant="secondary">
{objective.timeframe === 'quarterly' ? `${objective.quarter} ${objective.year}` : objective.year}
</Badge>
</div>
{objective.description && (
<CardDescription className="text-sm mt-1">
{objective.description}
</CardDescription>
)}
<div className="flex items-center gap-4 mt-3 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<User size={16} />
<span>{objective.owner}</span>
</div>
<div className="flex items-center gap-1">
<Calendar size={16} />
<span>Updated {new Date(objective.updatedAt).toLocaleDateString()}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => deleteObjective(objective.id)}
>
<Trash size={16} />
</Button>
</div>
</div>
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-muted-foreground">Overall Progress</span>
<span className="font-semibold">{Math.round(progress)}%</span>
</div>
<Progress value={progress} className="h-2" />
</div>
</CardHeader>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold flex items-center gap-2">
<CheckCircle size={20} weight="bold" className="text-accent" />
Key Results ({objective.keyResults.length})
</h4>
<Dialog open={isAddKRDialogOpen && selectedObjective?.id === objective.id} onOpenChange={(open) => {
setIsAddKRDialogOpen(open)
if (open) setSelectedObjective(objective)
}}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Plus size={14} weight="bold" />
Add Key Result
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Key Result</DialogTitle>
<DialogDescription>
Define a measurable key result for this objective
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="kr-description">Key Result Description *</Label>
<Textarea
id="kr-description"
value={newKeyResult.description}
onChange={(e) => setNewKeyResult({ ...newKeyResult, description: e.target.value })}
placeholder="e.g., Increase NPS score from 45 to 70"
rows={2}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="grid gap-2">
<Label htmlFor="start-value">Start Value</Label>
<Input
id="start-value"
type="number"
value={newKeyResult.startValue}
onChange={(e) => setNewKeyResult({ ...newKeyResult, startValue: parseFloat(e.target.value) })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="target-value">Target Value</Label>
<Input
id="target-value"
type="number"
value={newKeyResult.targetValue}
onChange={(e) => setNewKeyResult({ ...newKeyResult, targetValue: parseFloat(e.target.value) })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="unit">Unit</Label>
<Input
id="unit"
value={newKeyResult.unit}
onChange={(e) => setNewKeyResult({ ...newKeyResult, unit: e.target.value })}
placeholder="%"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddKRDialogOpen(false)}>
Cancel
</Button>
<Button onClick={addKeyResult}>Add Key Result</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{objective.keyResults.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm border-2 border-dashed rounded-lg">
No key results yet. Add key results to track progress.
</div>
) : (
<div className="space-y-4">
{objective.keyResults.map((kr) => (
<div key={kr.id} className="border rounded-lg p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<p className="font-medium text-sm">{kr.description}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
<span>{kr.startValue} {kr.unit} <ArrowRight size={12} className="inline" /> {kr.targetValue} {kr.unit}</span>
<Badge variant="outline" className={getStatusColor(kr.status)}>
{kr.status}
</Badge>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => deleteKeyResult(objective.id, kr.id)}
>
<Trash size={14} />
</Button>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Current: {kr.currentValue} {kr.unit}</span>
<span className="font-semibold">{Math.round(kr.progress)}%</span>
</div>
<Progress value={kr.progress} className="h-2" />
<div className="flex items-center gap-2 mt-3">
<Input
type="number"
placeholder="Update value"
className="h-8 text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') {
const input = e.target as HTMLInputElement
updateKeyResultProgress(objective.id, kr.id, parseFloat(input.value))
input.value = ''
}
}}
/>
<span className="text-xs text-muted-foreground whitespace-nowrap">Press Enter to update</span>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
})
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,520 @@
import { useKV } from '@github/spark/hooks'
import { useState, useMemo } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Separator } from '@/components/ui/separator'
import { Plus, Users, TrendUp, Warning, CheckCircle, ArrowRight, GitBranch, Clock } from '@phosphor-icons/react'
import { toast } from 'sonner'
import type { Initiative } from '@/types'
interface Dependency {
id: string
fromInitiativeId: string
fromInitiativeTitle: string
toInitiativeId: string
toInitiativeTitle: string
type: 'blocks' | 'enables' | 'informs'
description: string
status: 'active' | 'resolved'
createdAt: string
}
interface CapacityAllocation {
portfolioType: string
totalCapacity: number
allocatedCapacity: number
teamSize: number
utilizationRate: number
}
export default function PortfolioAnalysis() {
const [initiatives] = useKV<Initiative[]>('initiatives', [])
const [dependencies, setDependencies] = useKV<Dependency[]>('portfolio-dependencies', [])
const [isAddDependencyOpen, setIsAddDependencyOpen] = useState(false)
const [newDependency, setNewDependency] = useState({
fromInitiativeId: '',
toInitiativeId: '',
type: 'blocks' as const,
description: ''
})
const portfolios = [
{ type: 'operational-excellence', name: 'Operational Excellence', capacity: 100, teamSize: 12 },
{ type: 'ma', name: 'M&A', capacity: 80, teamSize: 8 },
{ type: 'financial-transformation', name: 'Financial Transformation', capacity: 120, teamSize: 15 },
{ type: 'esg', name: 'ESG', capacity: 60, teamSize: 6 },
{ type: 'innovation', name: 'Innovation', capacity: 90, teamSize: 10 }
]
const addDependency = () => {
if (!newDependency.fromInitiativeId || !newDependency.toInitiativeId || !newDependency.description) {
toast.error('Please fill in all fields')
return
}
if (newDependency.fromInitiativeId === newDependency.toInitiativeId) {
toast.error('Cannot create dependency to the same initiative')
return
}
const fromInit = initiatives?.find(i => i.id === newDependency.fromInitiativeId)
const toInit = initiatives?.find(i => i.id === newDependency.toInitiativeId)
if (!fromInit || !toInit) {
toast.error('Initiatives not found')
return
}
const dependency: Dependency = {
id: `dep-${Date.now()}`,
fromInitiativeId: newDependency.fromInitiativeId,
fromInitiativeTitle: fromInit.title,
toInitiativeId: newDependency.toInitiativeId,
toInitiativeTitle: toInit.title,
type: newDependency.type,
description: newDependency.description,
status: 'active',
createdAt: new Date().toISOString()
}
setDependencies((current) => [...(current || []), dependency])
setIsAddDependencyOpen(false)
setNewDependency({
fromInitiativeId: '',
toInitiativeId: '',
type: 'blocks',
description: ''
})
toast.success('Dependency added')
}
const resolveDependency = (id: string) => {
setDependencies((current) =>
(current || []).map(d => d.id === id ? { ...d, status: 'resolved' } : d)
)
toast.success('Dependency resolved')
}
const capacityData: CapacityAllocation[] = useMemo(() => {
return portfolios.map(portfolio => {
const portfolioInits = initiatives?.filter(i => i.portfolio === portfolio.type) || []
const allocatedCapacity = portfolioInits.length * 15
const utilizationRate = portfolio.capacity > 0 ? (allocatedCapacity / portfolio.capacity) * 100 : 0
return {
portfolioType: portfolio.type,
totalCapacity: portfolio.capacity,
allocatedCapacity: Math.min(allocatedCapacity, portfolio.capacity),
teamSize: portfolio.teamSize,
utilizationRate: Math.min(utilizationRate, 100)
}
})
}, [initiatives])
const alignmentAnalysis = useMemo(() => {
return portfolios.map(portfolio => {
const portfolioInits = initiatives?.filter(i => i.portfolio === portfolio.type) || []
const totalBudget = portfolioInits.reduce((sum, i) => sum + (i.budget || 0), 0)
const avgProgress = portfolioInits.length > 0
? portfolioInits.reduce((sum, i) => sum + i.progress, 0) / portfolioInits.length
: 0
const health = {
onTrack: portfolioInits.filter(i => i.status === 'on-track').length,
atRisk: portfolioInits.filter(i => i.status === 'at-risk').length,
blocked: portfolioInits.filter(i => i.status === 'blocked').length,
completed: portfolioInits.filter(i => i.status === 'completed').length
}
const strategicAlignment = portfolioInits.filter(i => i.strategyCardId).length / Math.max(portfolioInits.length, 1) * 100
return {
portfolio: portfolio.name,
type: portfolio.type,
count: portfolioInits.length,
totalBudget,
avgProgress: Math.round(avgProgress),
health,
strategicAlignment: Math.round(strategicAlignment)
}
})
}, [initiatives])
const riskAnalysis = useMemo(() => {
const blockedInitiatives = initiatives?.filter(i => i.status === 'blocked') || []
const atRiskInitiatives = initiatives?.filter(i => i.status === 'at-risk') || []
const activeDependencies = dependencies?.filter(d => d.status === 'active') || []
const blockingDeps = activeDependencies.filter(d => d.type === 'blocks')
return {
blockedCount: blockedInitiatives.length,
atRiskCount: atRiskInitiatives.length,
activeDependencyCount: activeDependencies.length,
blockingDependencyCount: blockingDeps.length
}
}, [initiatives, dependencies])
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount)
}
const getDependencyTypeColor = (type: string) => {
switch (type) {
case 'blocks': return 'destructive'
case 'enables': return 'default'
case 'informs': return 'secondary'
default: return 'outline'
}
}
return (
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Portfolio Analysis</h2>
<p className="text-muted-foreground mt-2">
Strategic alignment, capacity planning, and dependency management
</p>
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Blocked Initiatives</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-destructive">{riskAnalysis.blockedCount}</div>
<p className="text-xs text-muted-foreground mt-1">Need resolution</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">At Risk</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-at-risk">{riskAnalysis.atRiskCount}</div>
<p className="text-xs text-muted-foreground mt-1">Attention needed</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Active Dependencies</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{riskAnalysis.activeDependencyCount}</div>
<p className="text-xs text-muted-foreground mt-1">Cross-initiative links</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Blocking Dependencies</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-destructive">{riskAnalysis.blockingDependencyCount}</div>
<p className="text-xs text-muted-foreground mt-1">Critical path items</p>
</CardContent>
</Card>
</div>
<Tabs defaultValue="alignment" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="alignment">Strategic Alignment</TabsTrigger>
<TabsTrigger value="capacity">Capacity Planning</TabsTrigger>
<TabsTrigger value="dependencies">Dependencies</TabsTrigger>
</TabsList>
<TabsContent value="alignment" className="space-y-4 mt-6">
<Card>
<CardHeader>
<CardTitle>Portfolio Strategic Alignment</CardTitle>
<CardDescription>Initiative alignment, health, and budget by portfolio</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{alignmentAnalysis.map((portfolio) => (
<div key={portfolio.type}>
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="font-semibold text-lg">{portfolio.portfolio}</h4>
<p className="text-sm text-muted-foreground">
{portfolio.count} initiatives {formatCurrency(portfolio.totalBudget)} budget
</p>
</div>
<div className="text-right">
<div className="text-sm text-muted-foreground mb-1">Strategic Alignment</div>
<Badge variant={portfolio.strategicAlignment >= 80 ? 'default' : portfolio.strategicAlignment >= 50 ? 'secondary' : 'destructive'}>
{portfolio.strategicAlignment}%
</Badge>
</div>
</div>
<div className="grid grid-cols-5 gap-3 mb-3">
<div className="text-center p-2 bg-muted/50 rounded">
<div className="text-xs text-muted-foreground mb-1">On Track</div>
<div className="text-lg font-semibold text-success">{portfolio.health.onTrack}</div>
</div>
<div className="text-center p-2 bg-muted/50 rounded">
<div className="text-xs text-muted-foreground mb-1">At Risk</div>
<div className="text-lg font-semibold text-at-risk">{portfolio.health.atRisk}</div>
</div>
<div className="text-center p-2 bg-muted/50 rounded">
<div className="text-xs text-muted-foreground mb-1">Blocked</div>
<div className="text-lg font-semibold text-destructive">{portfolio.health.blocked}</div>
</div>
<div className="text-center p-2 bg-muted/50 rounded">
<div className="text-xs text-muted-foreground mb-1">Completed</div>
<div className="text-lg font-semibold text-accent">{portfolio.health.completed}</div>
</div>
<div className="text-center p-2 bg-muted/50 rounded">
<div className="text-xs text-muted-foreground mb-1">Avg Progress</div>
<div className="text-lg font-semibold">{portfolio.avgProgress}%</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Overall Progress</span>
<span className="font-semibold">{portfolio.avgProgress}%</span>
</div>
<Progress value={portfolio.avgProgress} className="h-2" />
</div>
<Separator className="mt-6" />
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="capacity" className="space-y-4 mt-6">
<Card>
<CardHeader>
<CardTitle>Capacity & Resource Planning</CardTitle>
<CardDescription>Team capacity utilization across portfolios</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{capacityData.map((capacity) => {
const portfolio = portfolios.find(p => p.type === capacity.portfolioType)
const isOverCapacity = capacity.utilizationRate >= 90
const isNearCapacity = capacity.utilizationRate >= 75 && capacity.utilizationRate < 90
return (
<div key={capacity.portfolioType}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Users size={20} className="text-primary" weight="bold" />
</div>
<div>
<h4 className="font-semibold">{portfolio?.name}</h4>
<p className="text-sm text-muted-foreground">
Team Size: {capacity.teamSize} members
</p>
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold">{Math.round(capacity.utilizationRate)}%</div>
<p className="text-xs text-muted-foreground">Utilization</p>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{capacity.allocatedCapacity} / {capacity.totalCapacity} capacity points
</span>
{isOverCapacity && (
<Badge variant="destructive" className="gap-1">
<Warning size={12} weight="fill" />
Over Capacity
</Badge>
)}
{isNearCapacity && (
<Badge variant="outline" className="gap-1 border-at-risk text-at-risk">
<Warning size={12} weight="fill" />
Near Capacity
</Badge>
)}
</div>
<Progress
value={capacity.utilizationRate}
className={`h-3 ${isOverCapacity ? '[&>div]:bg-destructive' : isNearCapacity ? '[&>div]:bg-at-risk' : ''}`}
/>
</div>
<Separator className="mt-6" />
</div>
)
})}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="dependencies" className="space-y-4 mt-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Initiative Dependencies</CardTitle>
<CardDescription>Track cross-initiative dependencies and blockers</CardDescription>
</div>
<Dialog open={isAddDependencyOpen} onOpenChange={setIsAddDependencyOpen}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus size={16} weight="bold" />
Add Dependency
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Initiative Dependency</DialogTitle>
<DialogDescription>
Define a dependency relationship between two initiatives
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="from-initiative">From Initiative</Label>
<Select
value={newDependency.fromInitiativeId}
onValueChange={(value) => setNewDependency({ ...newDependency, fromInitiativeId: value })}
>
<SelectTrigger id="from-initiative">
<SelectValue placeholder="Select initiative" />
</SelectTrigger>
<SelectContent>
{(initiatives || []).map(init => (
<SelectItem key={init.id} value={init.id}>
{init.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="dependency-type">Dependency Type</Label>
<Select
value={newDependency.type}
onValueChange={(value: any) => setNewDependency({ ...newDependency, type: value })}
>
<SelectTrigger id="dependency-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="blocks">Blocks (prevents progress)</SelectItem>
<SelectItem value="enables">Enables (allows to proceed)</SelectItem>
<SelectItem value="informs">Informs (provides info)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="to-initiative">To Initiative</Label>
<Select
value={newDependency.toInitiativeId}
onValueChange={(value) => setNewDependency({ ...newDependency, toInitiativeId: value })}
>
<SelectTrigger id="to-initiative">
<SelectValue placeholder="Select initiative" />
</SelectTrigger>
<SelectContent>
{(initiatives || []).map(init => (
<SelectItem key={init.id} value={init.id}>
{init.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={newDependency.description}
onChange={(e) => setNewDependency({ ...newDependency, description: e.target.value })}
placeholder="Describe the dependency..."
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddDependencyOpen(false)}>
Cancel
</Button>
<Button onClick={addDependency}>Add Dependency</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{(dependencies || []).length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<GitBranch size={48} className="mx-auto mb-4 opacity-50" />
<p>No dependencies tracked yet. Add dependencies to map initiative relationships.</p>
</div>
) : (
(dependencies || []).map((dep) => (
<Card key={dep.id} className={dep.status === 'resolved' ? 'opacity-60' : ''}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Badge variant={getDependencyTypeColor(dep.type)}>
{dep.type}
</Badge>
<Badge variant={dep.status === 'active' ? 'default' : 'outline'}>
{dep.status}
</Badge>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">{dep.fromInitiativeTitle}</span>
<ArrowRight size={16} className="text-muted-foreground" />
<span className="font-medium">{dep.toInitiativeTitle}</span>
</div>
<p className="text-sm text-muted-foreground mt-2">{dep.description}</p>
<p className="text-xs text-muted-foreground mt-2 flex items-center gap-1">
<Clock size={12} />
Created {new Date(dep.createdAt).toLocaleDateString()}
</p>
</div>
{dep.status === 'active' && (
<Button
size="sm"
variant="outline"
onClick={() => resolveDependency(dep.id)}
className="gap-2"
>
<CheckCircle size={16} />
Resolve
</Button>
)}
</div>
</CardContent>
</Card>
))
)}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -103,7 +103,9 @@ const initialFeatures: RoadmapFeature[] = [
description: 'Objectives and Key Results tracking and alignment',
category: 'workbench',
priority: 'medium',
completed: false
completed: true,
completedDate: new Date().toISOString().split('T')[0],
notes: 'Implemented comprehensive OKR management system with objectives, key results, progress tracking, category organization (company/team/individual), quarterly and annual timeframes, and real-time status updates with automatic objective status calculation based on key result progress.'
},
{
id: 'wb-5',
@@ -177,7 +179,9 @@ const initialFeatures: RoadmapFeature[] = [
description: 'Group initiatives into portfolios (M&A, OpEx, ESG, etc.)',
category: 'portfolio',
priority: 'high',
completed: false
completed: true,
completedDate: new Date().toISOString().split('T')[0],
notes: 'Implemented portfolio grouping system with predefined portfolio types including Operational Excellence, M&A, Financial Transformation, ESG, and Innovation. Initiatives can be assigned to portfolios during creation or editing.'
},
{
id: 'pf-2',
@@ -185,7 +189,9 @@ const initialFeatures: RoadmapFeature[] = [
description: 'Assess strategic alignment and impact across portfolios',
category: 'portfolio',
priority: 'high',
completed: false
completed: true,
completedDate: new Date().toISOString().split('T')[0],
notes: 'Built comprehensive portfolio analysis module showing strategic alignment percentage, initiative health distribution, budget allocation, and average progress across all portfolios with detailed drill-down capability.'
},
{
id: 'pf-3',
@@ -193,7 +199,9 @@ const initialFeatures: RoadmapFeature[] = [
description: 'Resource capacity planning and allocation',
category: 'portfolio',
priority: 'medium',
completed: false
completed: true,
completedDate: new Date().toISOString().split('T')[0],
notes: 'Created capacity planning module with team size tracking, utilization rate calculation, capacity threshold warnings (near/over capacity), and visual indicators for resource allocation across portfolios.'
},
{
id: 'pf-4',
@@ -201,7 +209,9 @@ const initialFeatures: RoadmapFeature[] = [
description: 'Track and visualize cross-initiative dependencies',
category: 'portfolio',
priority: 'medium',
completed: false
completed: true,
completedDate: new Date().toISOString().split('T')[0],
notes: 'Implemented dependency tracking system supporting three relationship types (blocks, enables, informs) with active/resolved status workflow, dependency visualization, and resolution workflow to manage critical path and initiative blockers.'
},
{
id: 'pf-5',
@@ -281,7 +291,9 @@ const initialFeatures: RoadmapFeature[] = [
description: 'Portfolio-level dashboards for leadership',
category: 'reporting',
priority: 'critical',
completed: false
completed: true,
completedDate: new Date().toISOString().split('T')[0],
notes: 'Built comprehensive executive dashboard with high-level KPIs, portfolio performance breakdowns, financial value realization tracking, initiative health distribution, and recent activity feed providing leadership with complete strategic oversight.'
},
{
id: 'rp-2',
@@ -297,7 +309,9 @@ const initialFeatures: RoadmapFeature[] = [
description: 'Link initiatives to financial results and savings',
category: 'reporting',
priority: 'critical',
completed: false
completed: true,
completedDate: new Date().toISOString().split('T')[0],
notes: 'Created financial tracking system linking initiatives to measurable financial outcomes including cost savings, revenue increase, cost avoidance, and efficiency gains with planned vs actual tracking, realization status workflow (projected → realized → validated), and category-based analysis with variance reporting.'
},
{
id: 'rp-4',