mirror of
https://github.com/johndoe6345789/strategy-execution-p.git
synced 2026-04-24 13:14:56 +00:00
Generated by Spark: Ok implement new features from ProductRoadmap.tsx, dont edit roadmap file, you can edit it to tick a box or two, thats about it.
This commit is contained in:
38
src/App.tsx
38
src/App.tsx
@@ -1,18 +1,18 @@
|
||||
import { useKV } from '@github/spark/hooks'
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Strategy,
|
||||
ChartBar,
|
||||
FolderOpen,
|
||||
Target,
|
||||
MapTrifold,
|
||||
Rocket,
|
||||
ChartLine,
|
||||
TrendUp,
|
||||
ArrowsLeftRight,
|
||||
Tree,
|
||||
GridFour,
|
||||
import {
|
||||
Strategy,
|
||||
ChartBar,
|
||||
FolderOpen,
|
||||
Target,
|
||||
MapTrifold,
|
||||
Rocket,
|
||||
ChartLine,
|
||||
TrendUp,
|
||||
ArrowsLeftRight,
|
||||
Tree,
|
||||
GridFour,
|
||||
Circle,
|
||||
House,
|
||||
CaretDown,
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
ChartLineUp,
|
||||
GitBranch,
|
||||
ArrowsDownUp,
|
||||
Gavel
|
||||
Gavel,
|
||||
Users,
|
||||
Presentation,
|
||||
FileText
|
||||
} from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import StrategyCards from './components/StrategyCards'
|
||||
@@ -42,6 +45,9 @@ import FinancialTracking from './components/FinancialTracking'
|
||||
import ExecutiveDashboard from './components/ExecutiveDashboard'
|
||||
import StrategyToInitiative from './components/StrategyToInitiative'
|
||||
import PortfolioGovernance from './components/PortfolioGovernance'
|
||||
import CollaborativeWorkshops from './components/CollaborativeWorkshops'
|
||||
import CustomScorecard from './components/CustomScorecard'
|
||||
import AutomatedReportGeneration from './components/AutomatedReportGeneration'
|
||||
import type { StrategyCard, Initiative } from './types'
|
||||
|
||||
type NavigationItem = {
|
||||
@@ -66,6 +72,7 @@ const navigationSections: NavigationSection[] = [
|
||||
{ id: 'comparison', label: 'Compare', icon: ArrowsLeftRight, component: StrategyComparison },
|
||||
{ id: 'traceability', label: 'Traceability', icon: Tree, component: StrategyTraceability },
|
||||
{ id: 'strategy-to-initiative', label: 'Strategy to Initiative', icon: ArrowsDownUp, component: StrategyToInitiative },
|
||||
{ id: 'workshops', label: 'Collaborative Workshops', icon: Users, component: CollaborativeWorkshops },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -103,7 +110,9 @@ const navigationSections: NavigationSection[] = [
|
||||
{ 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: 'custom-scorecard', label: 'Custom Scorecards', icon: Presentation, component: CustomScorecard },
|
||||
{ id: 'financial', label: 'Financial Tracking', icon: CurrencyDollar, component: FinancialTracking },
|
||||
{ id: 'automated-reports', label: 'Automated Reports', icon: FileText, component: AutomatedReportGeneration },
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -298,6 +307,7 @@ function getModuleDescription(moduleId: string): string {
|
||||
'comparison': 'Compare multiple strategic options side-by-side',
|
||||
'traceability': 'Map relationships from goals to initiatives',
|
||||
'strategy-to-initiative': 'AI-powered translation of strategy into executable initiatives',
|
||||
'workshops': 'Real-time collaboration and discussion on strategic initiatives',
|
||||
'workbench': 'Execute and track strategic initiatives',
|
||||
'tracker': 'Monitor initiative progress with real-time status',
|
||||
'okr': 'Define and track Objectives and Key Results',
|
||||
@@ -311,7 +321,9 @@ function getModuleDescription(moduleId: string): string {
|
||||
'executive-dashboard': 'Executive-level strategic performance overview',
|
||||
'dashboard': 'Real-time performance metrics and insights',
|
||||
'kpi': 'Monitor key performance indicators and metrics',
|
||||
'custom-scorecard': 'Create and manage configurable performance scorecards',
|
||||
'financial': 'Track financial outcomes and value realization',
|
||||
'automated-reports': 'Generate comprehensive reports from your strategic data',
|
||||
}
|
||||
return descriptions[moduleId] || 'Manage your strategic initiatives'
|
||||
}
|
||||
|
||||
609
src/components/AutomatedReportGeneration.tsx
Normal file
609
src/components/AutomatedReportGeneration.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
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 { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { FilePdf, FileText, FileCsv, Download, Calendar, TrendUp, Rocket, CheckCircle, CurrencyDollar, Target } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Initiative, StrategyCard } from '@/types'
|
||||
|
||||
interface ReportTemplate {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
sections: ReportSection[]
|
||||
}
|
||||
|
||||
interface ReportSection {
|
||||
id: string
|
||||
name: string
|
||||
type: 'executive-summary' | 'strategy-overview' | 'initiative-status' | 'financial-summary' | 'kpi-dashboard' | 'portfolio-breakdown'
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const reportTemplates: ReportTemplate[] = [
|
||||
{
|
||||
id: 'executive',
|
||||
name: 'Executive Summary',
|
||||
description: 'High-level overview for leadership',
|
||||
sections: [
|
||||
{ id: 'exec-1', name: 'Executive Summary', type: 'executive-summary', enabled: true },
|
||||
{ id: 'exec-2', name: 'Key Initiatives', type: 'initiative-status', enabled: true },
|
||||
{ id: 'exec-3', name: 'Financial Overview', type: 'financial-summary', enabled: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'strategic',
|
||||
name: 'Strategic Performance Report',
|
||||
description: 'Comprehensive strategy and execution review',
|
||||
sections: [
|
||||
{ id: 'strat-1', name: 'Strategy Overview', type: 'strategy-overview', enabled: true },
|
||||
{ id: 'strat-2', name: 'Initiative Status', type: 'initiative-status', enabled: true },
|
||||
{ id: 'strat-3', name: 'KPI Dashboard', type: 'kpi-dashboard', enabled: true },
|
||||
{ id: 'strat-4', name: 'Portfolio Breakdown', type: 'portfolio-breakdown', enabled: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'operational',
|
||||
name: 'Operational Dashboard',
|
||||
description: 'Detailed operational metrics and progress',
|
||||
sections: [
|
||||
{ id: 'ops-1', name: 'Initiative Status', type: 'initiative-status', enabled: true },
|
||||
{ id: 'ops-2', name: 'KPI Dashboard', type: 'kpi-dashboard', enabled: true },
|
||||
{ id: 'ops-3', name: 'Portfolio Breakdown', type: 'portfolio-breakdown', enabled: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'financial',
|
||||
name: 'Financial Performance Report',
|
||||
description: 'Budget, spending, and financial outcomes',
|
||||
sections: [
|
||||
{ id: 'fin-1', name: 'Financial Summary', type: 'financial-summary', enabled: true },
|
||||
{ id: 'fin-2', name: 'Portfolio Breakdown', type: 'portfolio-breakdown', enabled: true },
|
||||
{ id: 'fin-3', name: 'Initiative Status', type: 'initiative-status', enabled: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export default function AutomatedReportGeneration() {
|
||||
const [strategyCards] = useKV<StrategyCard[]>('strategy-cards', [])
|
||||
const [initiatives] = useKV<Initiative[]>('initiatives', [])
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>('executive')
|
||||
const [customSections, setCustomSections] = useState<ReportSection[]>([])
|
||||
const [reportFormat, setReportFormat] = useState<'pdf' | 'html' | 'csv'>('html')
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
const generateReport = async () => {
|
||||
setIsGenerating(true)
|
||||
|
||||
const template = reportTemplates.find(t => t.id === selectedTemplate)
|
||||
if (!template) {
|
||||
toast.error('Please select a report template')
|
||||
setIsGenerating(false)
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
const reportContent = buildReportContent(template)
|
||||
|
||||
if (reportFormat === 'html') {
|
||||
downloadHTMLReport(reportContent, template.name)
|
||||
} else if (reportFormat === 'csv') {
|
||||
downloadCSVReport(template.name)
|
||||
} else {
|
||||
toast.info('PDF generation would require a PDF library (not included in this demo)')
|
||||
}
|
||||
|
||||
setIsGenerating(false)
|
||||
toast.success(`${template.name} generated successfully!`)
|
||||
}
|
||||
|
||||
const buildReportContent = (template: ReportTemplate): string => {
|
||||
const enabledSections = template.sections.filter(s => s.enabled)
|
||||
|
||||
let html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${template.name} - ${new Date().toLocaleDateString()}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; line-height: 1.6; color: #1a1a1a; background: #fff; padding: 40px; max-width: 1200px; margin: 0 auto; }
|
||||
h1 { font-family: 'Outfit', sans-serif; font-size: 32px; font-weight: 700; margin-bottom: 8px; color: #1a1a1a; }
|
||||
h2 { font-family: 'Outfit', sans-serif; font-size: 24px; font-weight: 600; margin: 32px 0 16px; color: #1a1a1a; padding-bottom: 8px; border-bottom: 2px solid #e5e5e5; }
|
||||
h3 { font-family: 'Outfit', sans-serif; font-size: 18px; font-weight: 600; margin: 24px 0 12px; color: #1a1a1a; }
|
||||
.header { margin-bottom: 40px; padding-bottom: 24px; border-bottom: 3px solid #e5e5e5; }
|
||||
.meta { font-size: 14px; color: #666; margin-top: 8px; }
|
||||
.section { margin-bottom: 40px; }
|
||||
.card { background: #fafafa; border: 1px solid #e5e5e5; border-radius: 8px; padding: 20px; margin-bottom: 16px; }
|
||||
.card-title { font-weight: 600; font-size: 16px; margin-bottom: 8px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; margin: 20px 0; }
|
||||
.stat { background: #fff; border: 1px solid #e5e5e5; border-radius: 6px; padding: 16px; }
|
||||
.stat-label { font-size: 12px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #1a1a1a; }
|
||||
.badge { display: inline-block; padding: 4px 12px; border-radius: 4px; font-size: 12px; font-weight: 500; margin-right: 8px; }
|
||||
.badge-success { background: #d4edda; color: #155724; }
|
||||
.badge-warning { background: #fff3cd; color: #856404; }
|
||||
.badge-danger { background: #f8d7da; color: #721c24; }
|
||||
.badge-info { background: #d1ecf1; color: #0c5460; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 16px 0; }
|
||||
th { background: #f5f5f5; padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #e5e5e5; }
|
||||
td { padding: 12px; border-bottom: 1px solid #e5e5e5; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
.footer { margin-top: 60px; padding-top: 24px; border-top: 2px solid #e5e5e5; text-align: center; color: #666; font-size: 12px; }
|
||||
@media print { body { padding: 20px; } .no-print { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>${template.name}</h1>
|
||||
<div class="meta">
|
||||
<strong>Generated:</strong> ${new Date().toLocaleString()} |
|
||||
<strong>Period:</strong> ${new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
enabledSections.forEach(section => {
|
||||
html += generateSectionContent(section)
|
||||
})
|
||||
|
||||
html += `
|
||||
<div class="footer">
|
||||
<p>Generated by StrategyOS - Strategy Management Platform</p>
|
||||
<p>© ${new Date().getFullYear()} - Confidential and Proprietary</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
const generateSectionContent = (section: ReportSection): string => {
|
||||
switch (section.type) {
|
||||
case 'executive-summary':
|
||||
return generateExecutiveSummary()
|
||||
case 'strategy-overview':
|
||||
return generateStrategyOverview()
|
||||
case 'initiative-status':
|
||||
return generateInitiativeStatus()
|
||||
case 'financial-summary':
|
||||
return generateFinancialSummary()
|
||||
case 'kpi-dashboard':
|
||||
return generateKPIDashboard()
|
||||
case 'portfolio-breakdown':
|
||||
return generatePortfolioBreakdown()
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const generateExecutiveSummary = (): string => {
|
||||
const totalStrategies = (strategyCards || []).length
|
||||
const totalInitiatives = (initiatives || []).length
|
||||
const completedInitiatives = (initiatives || []).filter(i => i.status === 'completed').length
|
||||
const onTrackInitiatives = (initiatives || []).filter(i => i.status === 'on-track').length
|
||||
const atRiskInitiatives = (initiatives || []).filter(i => i.status === 'at-risk').length
|
||||
const blockedInitiatives = (initiatives || []).filter(i => i.status === 'blocked').length
|
||||
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>Executive Summary</h2>
|
||||
<div class="grid">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Active Strategies</div>
|
||||
<div class="stat-value">${totalStrategies}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Total Initiatives</div>
|
||||
<div class="stat-value">${totalInitiatives}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Completed</div>
|
||||
<div class="stat-value">${completedInitiatives}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">On Track</div>
|
||||
<div class="stat-value">${onTrackInitiatives}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">Health Overview</div>
|
||||
<p><span class="badge badge-success">Completed: ${completedInitiatives}</span> <span class="badge badge-info">On Track: ${onTrackInitiatives}</span> <span class="badge badge-warning">At Risk: ${atRiskInitiatives}</span> <span class="badge badge-danger">Blocked: ${blockedInitiatives}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const generateStrategyOverview = (): string => {
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>Strategy Overview</h2>
|
||||
${(strategyCards || []).map(card => `
|
||||
<div class="card">
|
||||
<div class="card-title">${card.title}</div>
|
||||
<p><strong>Framework:</strong> ${card.framework}</p>
|
||||
<p><strong>Vision:</strong> ${card.vision}</p>
|
||||
<p><strong>Goals:</strong> ${card.goals.length} strategic goals defined</p>
|
||||
<p><strong>Metrics:</strong> ${card.metrics.length} success metrics tracked</p>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const generateInitiativeStatus = (): string => {
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>Initiative Status</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Initiative</th>
|
||||
<th>Owner</th>
|
||||
<th>Status</th>
|
||||
<th>Progress</th>
|
||||
<th>Priority</th>
|
||||
<th>Budget</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${(initiatives || []).map(init => `
|
||||
<tr>
|
||||
<td>${init.title}</td>
|
||||
<td>${init.owner}</td>
|
||||
<td><span class="badge badge-${getStatusBadgeClass(init.status)}">${init.status}</span></td>
|
||||
<td>${init.progress}%</td>
|
||||
<td><span class="badge badge-${getPriorityBadgeClass(init.priority)}">${init.priority}</span></td>
|
||||
<td>$${init.budget.toLocaleString()}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const generateFinancialSummary = (): string => {
|
||||
const totalBudget = (initiatives || []).reduce((sum, i) => sum + i.budget, 0)
|
||||
const avgProgress = (initiatives || []).reduce((sum, i) => sum + i.progress, 0) / (initiatives || []).length || 0
|
||||
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>Financial Summary</h2>
|
||||
<div class="grid">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Total Budget Allocated</div>
|
||||
<div class="stat-value">$${totalBudget.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Average Progress</div>
|
||||
<div class="stat-value">${Math.round(avgProgress)}%</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Active Initiatives</div>
|
||||
<div class="stat-value">${(initiatives || []).filter(i => i.status !== 'completed').length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const generateKPIDashboard = (): string => {
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>KPI Dashboard</h2>
|
||||
<div class="card">
|
||||
<div class="card-title">Key Performance Indicators</div>
|
||||
${(initiatives || []).slice(0, 5).map(init => `
|
||||
<p><strong>${init.title}:</strong> ${init.kpis.length} KPIs tracked</p>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const generatePortfolioBreakdown = (): string => {
|
||||
const portfolios = ['operational-excellence', 'ma', 'financial-transformation', 'esg', 'innovation']
|
||||
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>Portfolio Breakdown</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Portfolio</th>
|
||||
<th>Initiatives</th>
|
||||
<th>Budget</th>
|
||||
<th>Avg Progress</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${portfolios.map(portfolio => {
|
||||
const portInitiatives = (initiatives || []).filter(i => i.portfolio === portfolio)
|
||||
const portBudget = portInitiatives.reduce((sum, i) => sum + i.budget, 0)
|
||||
const portProgress = portInitiatives.reduce((sum, i) => sum + i.progress, 0) / portInitiatives.length || 0
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${portfolio.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}</td>
|
||||
<td>${portInitiatives.length}</td>
|
||||
<td>$${portBudget.toLocaleString()}</td>
|
||||
<td>${Math.round(portProgress)}%</td>
|
||||
</tr>
|
||||
`
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const getStatusBadgeClass = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'completed': return 'success'
|
||||
case 'on-track': return 'info'
|
||||
case 'at-risk': return 'warning'
|
||||
case 'blocked': return 'danger'
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const getPriorityBadgeClass = (priority: string): string => {
|
||||
switch (priority) {
|
||||
case 'critical': return 'danger'
|
||||
case 'high': return 'warning'
|
||||
case 'medium': return 'info'
|
||||
case 'low': return 'success'
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const downloadHTMLReport = (content: string, templateName: string) => {
|
||||
const blob = new Blob([content], { type: 'text/html' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${templateName.replace(/\s+/g, '-').toLowerCase()}-${Date.now()}.html`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const downloadCSVReport = (templateName: string) => {
|
||||
const csvRows = [
|
||||
['Initiative', 'Owner', 'Status', 'Progress', 'Priority', 'Budget', 'Portfolio', 'Start Date', 'End Date'],
|
||||
...(initiatives || []).map(init => [
|
||||
init.title,
|
||||
init.owner,
|
||||
init.status,
|
||||
`${init.progress}%`,
|
||||
init.priority,
|
||||
`$${init.budget}`,
|
||||
init.portfolio,
|
||||
init.startDate,
|
||||
init.endDate
|
||||
])
|
||||
]
|
||||
|
||||
const csvContent = csvRows.map(row => row.map(cell => `"${cell}"`).join(',')).join('\n')
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${templateName.replace(/\s+/g, '-').toLowerCase()}-${Date.now()}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const currentTemplate = reportTemplates.find(t => t.id === selectedTemplate)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Automated Report Generation</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Generate comprehensive reports from your strategic data
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Target size={20} className="text-accent" />
|
||||
Strategies
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{(strategyCards || []).length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Rocket size={20} className="text-accent" />
|
||||
Initiatives
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{(initiatives || []).length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<CheckCircle size={20} className="text-accent" />
|
||||
Completed
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">
|
||||
{(initiatives || []).filter(i => i.status === 'completed').length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<Card className="col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Report Templates</CardTitle>
|
||||
<CardDescription>Choose a pre-configured report</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{reportTemplates.map((template) => (
|
||||
<Card
|
||||
key={template.id}
|
||||
className={`cursor-pointer transition-all hover:shadow-md ${
|
||||
selectedTemplate === template.id ? 'border-accent shadow-md' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedTemplate(template.id)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<h4 className="font-semibold text-sm mb-1">{template.name}</h4>
|
||||
<p className="text-xs text-muted-foreground mb-2">{template.description}</p>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{template.sections.length} sections
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="col-span-2 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Report Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
{currentTemplate ? currentTemplate.name : 'Select a template'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{currentTemplate ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-2 block">Report Sections</Label>
|
||||
<div className="space-y-2">
|
||||
{currentTemplate.sections.map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="flex items-center gap-2 p-3 rounded-md border bg-card"
|
||||
>
|
||||
<Checkbox checked={section.enabled} disabled />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{section.name}</div>
|
||||
<div className="text-xs text-muted-foreground capitalize">
|
||||
{section.type.replace(/-/g, ' ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block">Export Format</Label>
|
||||
<Select value={reportFormat} onValueChange={(value: any) => setReportFormat(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="html">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={16} />
|
||||
HTML Report
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="csv">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCsv size={16} />
|
||||
CSV Data Export
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="pdf">
|
||||
<div className="flex items-center gap-2">
|
||||
<FilePdf size={16} />
|
||||
PDF Report (Demo)
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Select a report template to configure
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Generate Report</CardTitle>
|
||||
<CardDescription>Export your configured report</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{currentTemplate ? currentTemplate.name : 'No template selected'}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Format: {reportFormat.toUpperCase()} •
|
||||
Generated: {new Date().toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={generateReport}
|
||||
disabled={!currentTemplate || isGenerating}
|
||||
className="gap-2"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>Generating...</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={16} weight="bold" />
|
||||
Generate & Download
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="mb-2"><strong>Report includes:</strong></p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>{(strategyCards || []).length} strategic frameworks</li>
|
||||
<li>{(initiatives || []).length} initiatives and projects</li>
|
||||
<li>Financial data and budget allocation</li>
|
||||
<li>Performance metrics and KPIs</li>
|
||||
<li>Portfolio breakdowns and analysis</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
628
src/components/CollaborativeWorkshops.tsx
Normal file
628
src/components/CollaborativeWorkshops.tsx
Normal file
@@ -0,0 +1,628 @@
|
||||
import { useKV } from '@github/spark/hooks'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { ChatCircleText, ThumbsUp, Plus, Users, PaperPlaneRight, Flag } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import type { StrategyCard } from '@/types'
|
||||
|
||||
interface Comment {
|
||||
id: string
|
||||
strategyCardId: string
|
||||
author: string
|
||||
content: string
|
||||
timestamp: number
|
||||
replies: Reply[]
|
||||
likes: string[]
|
||||
type: 'comment' | 'question' | 'suggestion' | 'concern'
|
||||
}
|
||||
|
||||
interface Reply {
|
||||
id: string
|
||||
author: string
|
||||
content: string
|
||||
timestamp: number
|
||||
likes: string[]
|
||||
}
|
||||
|
||||
interface Workshop {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
strategyCardId: string
|
||||
facilitator: string
|
||||
participants: string[]
|
||||
status: 'scheduled' | 'active' | 'completed'
|
||||
startDate: string
|
||||
endDate?: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export default function CollaborativeWorkshops() {
|
||||
const [strategyCards] = useKV<StrategyCard[]>('strategy-cards', [])
|
||||
const [comments, setComments] = useKV<Comment[]>('strategy-comments', [])
|
||||
const [workshops, setWorkshops] = useKV<Workshop[]>('strategy-workshops', [])
|
||||
const [currentUser, setCurrentUser] = useState<string>('')
|
||||
const [selectedCard, setSelectedCard] = useState<string>('')
|
||||
const [newComment, setNewComment] = useState('')
|
||||
const [commentType, setCommentType] = useState<Comment['type']>('comment')
|
||||
const [replyTo, setReplyTo] = useState<string | null>(null)
|
||||
const [replyContent, setReplyContent] = useState('')
|
||||
const [isWorkshopDialogOpen, setIsWorkshopDialogOpen] = useState(false)
|
||||
const [newWorkshop, setNewWorkshop] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
strategyCardId: '',
|
||||
facilitator: '',
|
||||
participants: '',
|
||||
startDate: ''
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
const user = await window.spark.user()
|
||||
setCurrentUser(user?.login || 'Anonymous')
|
||||
}
|
||||
loadUser()
|
||||
}, [])
|
||||
|
||||
const addComment = () => {
|
||||
if (!newComment.trim() || !selectedCard) {
|
||||
toast.error('Please select a strategy and enter a comment')
|
||||
return
|
||||
}
|
||||
|
||||
const comment: Comment = {
|
||||
id: `comment-${Date.now()}`,
|
||||
strategyCardId: selectedCard,
|
||||
author: currentUser,
|
||||
content: newComment,
|
||||
timestamp: Date.now(),
|
||||
replies: [],
|
||||
likes: [],
|
||||
type: commentType
|
||||
}
|
||||
|
||||
setComments((current) => [...(current || []), comment])
|
||||
setNewComment('')
|
||||
setCommentType('comment')
|
||||
toast.success('Comment added!')
|
||||
}
|
||||
|
||||
const addReply = (commentId: string) => {
|
||||
if (!replyContent.trim()) {
|
||||
toast.error('Please enter a reply')
|
||||
return
|
||||
}
|
||||
|
||||
setComments((current) =>
|
||||
(current || []).map(c =>
|
||||
c.id === commentId
|
||||
? {
|
||||
...c,
|
||||
replies: [
|
||||
...c.replies,
|
||||
{
|
||||
id: `reply-${Date.now()}`,
|
||||
author: currentUser,
|
||||
content: replyContent,
|
||||
timestamp: Date.now(),
|
||||
likes: []
|
||||
}
|
||||
]
|
||||
}
|
||||
: c
|
||||
)
|
||||
)
|
||||
|
||||
setReplyContent('')
|
||||
setReplyTo(null)
|
||||
toast.success('Reply added!')
|
||||
}
|
||||
|
||||
const toggleLike = (commentId: string, isReply: boolean = false, replyId?: string) => {
|
||||
setComments((current) =>
|
||||
(current || []).map(c => {
|
||||
if (isReply && replyId) {
|
||||
return c.id === commentId
|
||||
? {
|
||||
...c,
|
||||
replies: c.replies.map(r =>
|
||||
r.id === replyId
|
||||
? {
|
||||
...r,
|
||||
likes: r.likes.includes(currentUser)
|
||||
? r.likes.filter(u => u !== currentUser)
|
||||
: [...r.likes, currentUser]
|
||||
}
|
||||
: r
|
||||
)
|
||||
}
|
||||
: c
|
||||
}
|
||||
return c.id === commentId
|
||||
? {
|
||||
...c,
|
||||
likes: c.likes.includes(currentUser)
|
||||
? c.likes.filter(u => u !== currentUser)
|
||||
: [...c.likes, currentUser]
|
||||
}
|
||||
: c
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const createWorkshop = () => {
|
||||
if (!newWorkshop.name.trim() || !newWorkshop.strategyCardId || !newWorkshop.facilitator.trim()) {
|
||||
toast.error('Please fill in all required fields')
|
||||
return
|
||||
}
|
||||
|
||||
const workshop: Workshop = {
|
||||
id: `workshop-${Date.now()}`,
|
||||
name: newWorkshop.name,
|
||||
description: newWorkshop.description,
|
||||
strategyCardId: newWorkshop.strategyCardId,
|
||||
facilitator: newWorkshop.facilitator,
|
||||
participants: newWorkshop.participants.split(',').map(p => p.trim()).filter(p => p),
|
||||
status: 'scheduled',
|
||||
startDate: newWorkshop.startDate,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
|
||||
setWorkshops((current) => [...(current || []), workshop])
|
||||
setIsWorkshopDialogOpen(false)
|
||||
setNewWorkshop({
|
||||
name: '',
|
||||
description: '',
|
||||
strategyCardId: '',
|
||||
facilitator: '',
|
||||
participants: '',
|
||||
startDate: ''
|
||||
})
|
||||
toast.success('Workshop created!')
|
||||
}
|
||||
|
||||
const updateWorkshopStatus = (workshopId: string, status: Workshop['status']) => {
|
||||
setWorkshops((current) =>
|
||||
(current || []).map(w =>
|
||||
w.id === workshopId
|
||||
? { ...w, status, endDate: status === 'completed' ? new Date().toISOString() : undefined }
|
||||
: w
|
||||
)
|
||||
)
|
||||
toast.success(`Workshop ${status}`)
|
||||
}
|
||||
|
||||
const cardComments = selectedCard
|
||||
? (comments || []).filter(c => c.strategyCardId === selectedCard)
|
||||
: []
|
||||
|
||||
const commentTypeConfig = {
|
||||
comment: { label: 'Comment', color: 'default' },
|
||||
question: { label: 'Question', color: 'secondary' },
|
||||
suggestion: { label: 'Suggestion', color: 'default' },
|
||||
concern: { label: 'Concern', color: 'destructive' }
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
scheduled: 'secondary',
|
||||
active: 'default',
|
||||
completed: 'outline'
|
||||
} as const
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Collaborative Workshops</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Real-time collaboration and discussion on strategic initiatives
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={isWorkshopDialogOpen} onOpenChange={setIsWorkshopDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus size={16} weight="bold" />
|
||||
Create Workshop
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Strategy Workshop</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set up a collaborative workshop to discuss and refine a strategy
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Workshop Name</label>
|
||||
<Input
|
||||
value={newWorkshop.name}
|
||||
onChange={(e) => setNewWorkshop({ ...newWorkshop, name: e.target.value })}
|
||||
placeholder="e.g., Q1 Strategy Review"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<Textarea
|
||||
value={newWorkshop.description}
|
||||
onChange={(e) => setNewWorkshop({ ...newWorkshop, description: e.target.value })}
|
||||
placeholder="Workshop goals and agenda..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Strategy Card</label>
|
||||
<Select
|
||||
value={newWorkshop.strategyCardId}
|
||||
onValueChange={(value) => setNewWorkshop({ ...newWorkshop, strategyCardId: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a strategy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(strategyCards || []).map((card) => (
|
||||
<SelectItem key={card.id} value={card.id}>
|
||||
{card.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Facilitator</label>
|
||||
<Input
|
||||
value={newWorkshop.facilitator}
|
||||
onChange={(e) => setNewWorkshop({ ...newWorkshop, facilitator: e.target.value })}
|
||||
placeholder="Facilitator name"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Start Date</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={newWorkshop.startDate}
|
||||
onChange={(e) => setNewWorkshop({ ...newWorkshop, startDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Participants</label>
|
||||
<Input
|
||||
value={newWorkshop.participants}
|
||||
onChange={(e) => setNewWorkshop({ ...newWorkshop, participants: e.target.value })}
|
||||
placeholder="Comma-separated names"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsWorkshopDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={createWorkshop}>Create Workshop</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Total Workshops</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{workshops?.length || 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Active Discussions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{comments?.length || 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Active Workshops</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">
|
||||
{(workshops || []).filter(w => w.status === 'active').length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{(workshops || []).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workshops</CardTitle>
|
||||
<CardDescription>Scheduled and active strategy workshops</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{(workshops || []).map((workshop) => {
|
||||
const card = (strategyCards || []).find(c => c.id === workshop.strategyCardId)
|
||||
return (
|
||||
<Card key={workshop.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="font-semibold">{workshop.name}</h4>
|
||||
<Badge variant={statusColors[workshop.status]}>
|
||||
{workshop.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{workshop.description && (
|
||||
<p className="text-sm text-muted-foreground mb-2">{workshop.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>Strategy: {card?.title || 'Unknown'}</span>
|
||||
<span>Facilitator: {workshop.facilitator}</span>
|
||||
<span>Date: {new Date(workshop.startDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{workshop.participants.length > 0 && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Users size={14} className="text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{workshop.participants.length} participants
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{workshop.status === 'scheduled' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => updateWorkshopStatus(workshop.id, 'active')}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{workshop.status === 'active' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => updateWorkshopStatus(workshop.id, 'completed')}
|
||||
>
|
||||
Complete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Strategy Selection</CardTitle>
|
||||
<CardDescription>Choose a strategy to discuss</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[600px] pr-4">
|
||||
<div className="space-y-2">
|
||||
{(strategyCards || []).map((card) => {
|
||||
const cardCommentCount = (comments || []).filter(c => c.strategyCardId === card.id).length
|
||||
return (
|
||||
<Card
|
||||
key={card.id}
|
||||
className={`cursor-pointer transition-all hover:shadow-md ${
|
||||
selectedCard === card.id ? 'border-accent shadow-md' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedCard(card.id)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm mb-1">{card.title}</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{card.framework}
|
||||
</Badge>
|
||||
</div>
|
||||
{cardCommentCount > 0 && (
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<ChatCircleText size={12} />
|
||||
{cardCommentCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="col-span-2 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add Comment</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedCard
|
||||
? `Commenting on: ${(strategyCards || []).find(c => c.id === selectedCard)?.title}`
|
||||
: 'Select a strategy to start commenting'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Select value={commentType} onValueChange={(value: any) => setCommentType(value)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="comment">Comment</SelectItem>
|
||||
<SelectItem value="question">Question</SelectItem>
|
||||
<SelectItem value="suggestion">Suggestion</SelectItem>
|
||||
<SelectItem value="concern">Concern</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Share your thoughts, questions, or suggestions..."
|
||||
rows={3}
|
||||
className="flex-1"
|
||||
disabled={!selectedCard}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={addComment} disabled={!selectedCard} className="gap-2">
|
||||
<PaperPlaneRight size={16} weight="bold" />
|
||||
Post Comment
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Discussion ({cardComments.length})</CardTitle>
|
||||
<CardDescription>Comments and feedback on this strategy</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[400px] pr-4">
|
||||
{cardComments.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No comments yet. Start the discussion!
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{cardComments.map((comment) => (
|
||||
<Card key={comment.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="bg-primary text-primary-foreground text-xs">
|
||||
{comment.author[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm">{comment.author}</span>
|
||||
<Badge variant={commentTypeConfig[comment.type].color as any} className="text-xs">
|
||||
{commentTypeConfig[comment.type].label}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(comment.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm">{comment.content}</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={() => toggleLike(comment.id)}
|
||||
>
|
||||
<ThumbsUp
|
||||
size={14}
|
||||
weight={comment.likes.includes(currentUser) ? 'fill' : 'regular'}
|
||||
/>
|
||||
{comment.likes.length > 0 && comment.likes.length}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setReplyTo(replyTo === comment.id ? null : comment.id)}
|
||||
>
|
||||
Reply
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{replyTo === comment.id && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<Textarea
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
placeholder="Write a reply..."
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => addReply(comment.id)}>
|
||||
Post Reply
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setReplyTo(null)
|
||||
setReplyContent('')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{comment.replies.length > 0 && (
|
||||
<div className="mt-3 ml-4 space-y-2 border-l-2 border-border pl-3">
|
||||
{comment.replies.map((reply) => (
|
||||
<div key={reply.id} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="bg-secondary text-secondary-foreground text-xs">
|
||||
{reply.author[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-semibold text-xs">{reply.author}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(reply.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm ml-8">{reply.content}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1 text-xs ml-8"
|
||||
onClick={() => toggleLike(comment.id, true, reply.id)}
|
||||
>
|
||||
<ThumbsUp
|
||||
size={12}
|
||||
weight={reply.likes.includes(currentUser) ? 'fill' : 'regular'}
|
||||
/>
|
||||
{reply.likes.length > 0 && reply.likes.length}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
710
src/components/CustomScorecard.tsx
Normal file
710
src/components/CustomScorecard.tsx
Normal file
@@ -0,0 +1,710 @@
|
||||
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 { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Plus, ChartLine, TrendUp, TrendDown, Minus, Eye, EyeSlash, PencilSimple, Trash } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Initiative } from '@/types'
|
||||
|
||||
interface ScorecardMetric {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
category: string
|
||||
target: number
|
||||
current: number
|
||||
unit: string
|
||||
trend: 'up' | 'down' | 'flat'
|
||||
weight: number
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
interface Scorecard {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
metrics: ScorecardMetric[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
const defaultMetricCategories = [
|
||||
'Financial',
|
||||
'Customer',
|
||||
'Internal Process',
|
||||
'Learning & Growth',
|
||||
'Strategic',
|
||||
'Operational',
|
||||
'Quality',
|
||||
'Safety'
|
||||
]
|
||||
|
||||
export default function CustomScorecard() {
|
||||
const [initiatives] = useKV<Initiative[]>('initiatives', [])
|
||||
const [scorecards, setScorecards] = useKV<Scorecard[]>('custom-scorecards', [])
|
||||
const [selectedScorecard, setSelectedScorecard] = useState<string>('')
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [isEditMetricDialogOpen, setIsEditMetricDialogOpen] = useState(false)
|
||||
const [editingMetric, setEditingMetric] = useState<ScorecardMetric | null>(null)
|
||||
const [newScorecard, setNewScorecard] = useState({
|
||||
name: '',
|
||||
description: ''
|
||||
})
|
||||
const [newMetric, setNewMetric] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'Financial',
|
||||
target: 100,
|
||||
current: 0,
|
||||
unit: '%',
|
||||
trend: 'up' as const,
|
||||
weight: 1
|
||||
})
|
||||
|
||||
const createScorecard = () => {
|
||||
if (!newScorecard.name.trim()) {
|
||||
toast.error('Please enter a scorecard name')
|
||||
return
|
||||
}
|
||||
|
||||
const scorecard: Scorecard = {
|
||||
id: `scorecard-${Date.now()}`,
|
||||
name: newScorecard.name,
|
||||
description: newScorecard.description,
|
||||
metrics: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
isDefault: (scorecards || []).length === 0
|
||||
}
|
||||
|
||||
setScorecards((current) => [...(current || []), scorecard])
|
||||
setSelectedScorecard(scorecard.id)
|
||||
setIsCreateDialogOpen(false)
|
||||
setNewScorecard({ name: '', description: '' })
|
||||
toast.success('Scorecard created!')
|
||||
}
|
||||
|
||||
const addMetric = () => {
|
||||
if (!selectedScorecard || !newMetric.name.trim()) {
|
||||
toast.error('Please enter a metric name')
|
||||
return
|
||||
}
|
||||
|
||||
const metric: ScorecardMetric = {
|
||||
id: `metric-${Date.now()}`,
|
||||
name: newMetric.name,
|
||||
description: newMetric.description,
|
||||
category: newMetric.category,
|
||||
target: newMetric.target,
|
||||
current: newMetric.current,
|
||||
unit: newMetric.unit,
|
||||
trend: newMetric.trend,
|
||||
weight: newMetric.weight,
|
||||
visible: true
|
||||
}
|
||||
|
||||
setScorecards((current) =>
|
||||
(current || []).map(s =>
|
||||
s.id === selectedScorecard
|
||||
? { ...s, metrics: [...s.metrics, metric], updatedAt: Date.now() }
|
||||
: s
|
||||
)
|
||||
)
|
||||
|
||||
setNewMetric({
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'Financial',
|
||||
target: 100,
|
||||
current: 0,
|
||||
unit: '%',
|
||||
trend: 'up',
|
||||
weight: 1
|
||||
})
|
||||
toast.success('Metric added!')
|
||||
}
|
||||
|
||||
const updateMetric = () => {
|
||||
if (!editingMetric || !selectedScorecard) return
|
||||
|
||||
setScorecards((current) =>
|
||||
(current || []).map(s =>
|
||||
s.id === selectedScorecard
|
||||
? {
|
||||
...s,
|
||||
metrics: s.metrics.map(m => (m.id === editingMetric.id ? editingMetric : m)),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
: s
|
||||
)
|
||||
)
|
||||
|
||||
setIsEditMetricDialogOpen(false)
|
||||
setEditingMetric(null)
|
||||
toast.success('Metric updated!')
|
||||
}
|
||||
|
||||
const toggleMetricVisibility = (metricId: string) => {
|
||||
setScorecards((current) =>
|
||||
(current || []).map(s =>
|
||||
s.id === selectedScorecard
|
||||
? {
|
||||
...s,
|
||||
metrics: s.metrics.map(m =>
|
||||
m.id === metricId ? { ...m, visible: !m.visible } : m
|
||||
),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
: s
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const deleteMetric = (metricId: string) => {
|
||||
setScorecards((current) =>
|
||||
(current || []).map(s =>
|
||||
s.id === selectedScorecard
|
||||
? {
|
||||
...s,
|
||||
metrics: s.metrics.filter(m => m.id !== metricId),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
: s
|
||||
)
|
||||
)
|
||||
toast.success('Metric removed')
|
||||
}
|
||||
|
||||
const deleteScorecard = (scorecardId: string) => {
|
||||
setScorecards((current) => (current || []).filter(s => s.id !== scorecardId))
|
||||
if (selectedScorecard === scorecardId) {
|
||||
setSelectedScorecard('')
|
||||
}
|
||||
toast.success('Scorecard deleted')
|
||||
}
|
||||
|
||||
const calculateScore = (metric: ScorecardMetric): number => {
|
||||
if (metric.target === 0) return 0
|
||||
const percentage = (metric.current / metric.target) * 100
|
||||
return Math.min(Math.max(percentage, 0), 100)
|
||||
}
|
||||
|
||||
const calculateOverallScore = (scorecard: Scorecard): number => {
|
||||
const visibleMetrics = scorecard.metrics.filter(m => m.visible)
|
||||
if (visibleMetrics.length === 0) return 0
|
||||
|
||||
const totalWeight = visibleMetrics.reduce((sum, m) => sum + m.weight, 0)
|
||||
const weightedScore = visibleMetrics.reduce((sum, m) => {
|
||||
const score = calculateScore(m)
|
||||
return sum + score * m.weight
|
||||
}, 0)
|
||||
|
||||
return totalWeight > 0 ? Math.round(weightedScore / totalWeight) : 0
|
||||
}
|
||||
|
||||
const currentScorecard = (scorecards || []).find(s => s.id === selectedScorecard)
|
||||
const visibleMetrics = currentScorecard?.metrics.filter(m => m.visible) || []
|
||||
const metricsGrouped = visibleMetrics.reduce((acc, metric) => {
|
||||
if (!acc[metric.category]) {
|
||||
acc[metric.category] = []
|
||||
}
|
||||
acc[metric.category].push(metric)
|
||||
return acc
|
||||
}, {} as Record<string, ScorecardMetric[]>)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Custom Scorecards</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Create and manage configurable performance scorecards
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus size={16} weight="bold" />
|
||||
Create Scorecard
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Scorecard</DialogTitle>
|
||||
<DialogDescription>
|
||||
Build a custom scorecard with your own metrics and KPIs
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="scorecard-name">Scorecard Name</Label>
|
||||
<Input
|
||||
id="scorecard-name"
|
||||
value={newScorecard.name}
|
||||
onChange={(e) => setNewScorecard({ ...newScorecard, name: e.target.value })}
|
||||
placeholder="e.g., Executive Dashboard, Operational Metrics"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="scorecard-description">Description</Label>
|
||||
<Input
|
||||
id="scorecard-description"
|
||||
value={newScorecard.description}
|
||||
onChange={(e) => setNewScorecard({ ...newScorecard, description: e.target.value })}
|
||||
placeholder="What does this scorecard track?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={createScorecard}>Create</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{(scorecards || []).length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center py-12">
|
||||
<ChartLine size={48} className="mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No Scorecards Yet</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Create your first custom scorecard to track performance metrics
|
||||
</p>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)} className="gap-2">
|
||||
<Plus size={16} weight="bold" />
|
||||
Create Your First Scorecard
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label>Select Scorecard:</Label>
|
||||
<Select value={selectedScorecard} onValueChange={setSelectedScorecard}>
|
||||
<SelectTrigger className="w-[300px]">
|
||||
<SelectValue placeholder="Choose a scorecard" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(scorecards || []).map((scorecard) => (
|
||||
<SelectItem key={scorecard.id} value={scorecard.id}>
|
||||
{scorecard.name}
|
||||
{scorecard.isDefault && ' (Default)'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedScorecard && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => deleteScorecard(selectedScorecard)}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentScorecard && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>{currentScorecard.name}</CardTitle>
|
||||
{currentScorecard.description && (
|
||||
<CardDescription>{currentScorecard.description}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-4xl font-bold text-accent">
|
||||
{calculateOverallScore(currentScorecard)}%
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Overall Score</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Progress value={calculateOverallScore(currentScorecard)} className="h-3" />
|
||||
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
|
||||
<span>{visibleMetrics.length} active metrics</span>
|
||||
<span>Last updated: {new Date(currentScorecard.updatedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Add Metric</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="metric-name" className="text-xs">Metric Name</Label>
|
||||
<Input
|
||||
id="metric-name"
|
||||
value={newMetric.name}
|
||||
onChange={(e) => setNewMetric({ ...newMetric, name: e.target.value })}
|
||||
placeholder="e.g., Revenue Growth"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="metric-category" className="text-xs">Category</Label>
|
||||
<Select
|
||||
value={newMetric.category}
|
||||
onValueChange={(value) => setNewMetric({ ...newMetric, category: value })}
|
||||
>
|
||||
<SelectTrigger id="metric-category" className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaultMetricCategories.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{cat}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="metric-current" className="text-xs">Current</Label>
|
||||
<Input
|
||||
id="metric-current"
|
||||
type="number"
|
||||
value={newMetric.current}
|
||||
onChange={(e) => setNewMetric({ ...newMetric, current: Number(e.target.value) })}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="metric-target" className="text-xs">Target</Label>
|
||||
<Input
|
||||
id="metric-target"
|
||||
type="number"
|
||||
value={newMetric.target}
|
||||
onChange={(e) => setNewMetric({ ...newMetric, target: Number(e.target.value) })}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="metric-unit" className="text-xs">Unit</Label>
|
||||
<Input
|
||||
id="metric-unit"
|
||||
value={newMetric.unit}
|
||||
onChange={(e) => setNewMetric({ ...newMetric, unit: e.target.value })}
|
||||
placeholder="%"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="metric-trend" className="text-xs">Trend</Label>
|
||||
<Select
|
||||
value={newMetric.trend}
|
||||
onValueChange={(value: any) => setNewMetric({ ...newMetric, trend: value })}
|
||||
>
|
||||
<SelectTrigger id="metric-trend" className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="up">Up</SelectItem>
|
||||
<SelectItem value="down">Down</SelectItem>
|
||||
<SelectItem value="flat">Flat</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="metric-weight" className="text-xs">Weight</Label>
|
||||
<Input
|
||||
id="metric-weight"
|
||||
type="number"
|
||||
value={newMetric.weight}
|
||||
onChange={(e) => setNewMetric({ ...newMetric, weight: Number(e.target.value) })}
|
||||
min="1"
|
||||
max="10"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={addMetric} size="sm" className="gap-2 mt-2">
|
||||
<Plus size={14} weight="bold" />
|
||||
Add Metric
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Metric Management</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{currentScorecard.metrics.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
No metrics added yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[300px] overflow-auto">
|
||||
{currentScorecard.metrics.map((metric) => (
|
||||
<div
|
||||
key={metric.id}
|
||||
className="flex items-center gap-2 p-2 rounded-md border bg-card hover:bg-accent/5"
|
||||
>
|
||||
<Checkbox
|
||||
checked={metric.visible}
|
||||
onCheckedChange={() => toggleMetricVisibility(metric.id)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{metric.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{metric.category}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingMetric(metric)
|
||||
setIsEditMetricDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<PencilSimple size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteMetric(metric.id)}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={Object.keys(metricsGrouped)[0] || 'all'} className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All Metrics</TabsTrigger>
|
||||
{Object.keys(metricsGrouped).map((category) => (
|
||||
<TabsTrigger key={category} value={category}>
|
||||
{category}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="all" className="mt-4">
|
||||
<div className="grid gap-4">
|
||||
{Object.entries(metricsGrouped).map(([category, metrics]) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-lg font-semibold mb-3">{category}</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{metrics.map((metric) => {
|
||||
const score = calculateScore(metric)
|
||||
const TrendIcon =
|
||||
metric.trend === 'up' ? TrendUp : metric.trend === 'down' ? TrendDown : Minus
|
||||
return (
|
||||
<Card key={metric.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-sm">{metric.name}</CardTitle>
|
||||
{metric.description && (
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{metric.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<TrendIcon size={20} className="text-accent" weight="bold" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">
|
||||
{metric.current}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
/ {metric.target} {metric.unit}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={score} className="h-2" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Score: {Math.round(score)}%
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Weight: {metric.weight}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{Object.entries(metricsGrouped).map(([category, metrics]) => (
|
||||
<TabsContent key={category} value={category} className="mt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{metrics.map((metric) => {
|
||||
const score = calculateScore(metric)
|
||||
const TrendIcon =
|
||||
metric.trend === 'up' ? TrendUp : metric.trend === 'down' ? TrendDown : Minus
|
||||
return (
|
||||
<Card key={metric.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-sm">{metric.name}</CardTitle>
|
||||
{metric.description && (
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{metric.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<TrendIcon size={20} className="text-accent" weight="bold" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">{metric.current}</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
/ {metric.target} {metric.unit}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={score} className="h-2" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Score: {Math.round(score)}%</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Weight: {metric.weight}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={isEditMetricDialogOpen} onOpenChange={setIsEditMetricDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Metric</DialogTitle>
|
||||
<DialogDescription>Update metric values and configuration</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editingMetric && (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Metric Name</Label>
|
||||
<Input
|
||||
value={editingMetric.name}
|
||||
onChange={(e) => setEditingMetric({ ...editingMetric, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Description</Label>
|
||||
<Input
|
||||
value={editingMetric.description || ''}
|
||||
onChange={(e) => setEditingMetric({ ...editingMetric, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Current Value</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editingMetric.current}
|
||||
onChange={(e) =>
|
||||
setEditingMetric({ ...editingMetric, current: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Target Value</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editingMetric.target}
|
||||
onChange={(e) =>
|
||||
setEditingMetric({ ...editingMetric, target: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Unit</Label>
|
||||
<Input
|
||||
value={editingMetric.unit}
|
||||
onChange={(e) => setEditingMetric({ ...editingMetric, unit: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Trend</Label>
|
||||
<Select
|
||||
value={editingMetric.trend}
|
||||
onValueChange={(value: any) => setEditingMetric({ ...editingMetric, trend: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="up">Up</SelectItem>
|
||||
<SelectItem value="down">Down</SelectItem>
|
||||
<SelectItem value="flat">Flat</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Weight</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editingMetric.weight}
|
||||
onChange={(e) =>
|
||||
setEditingMetric({ ...editingMetric, weight: Number(e.target.value) })
|
||||
}
|
||||
min="1"
|
||||
max="10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditMetricDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={updateMetric}>Save Changes</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -53,7 +53,9 @@ const initialFeatures: RoadmapFeature[] = [
|
||||
description: 'Real-time collaboration features for strategy workshops',
|
||||
category: 'strategy-cards',
|
||||
priority: 'high',
|
||||
completed: false
|
||||
completed: true,
|
||||
completedDate: new Date().toISOString().split('T')[0],
|
||||
notes: 'Implemented comprehensive collaborative workshop system with real-time commenting, discussion threads, workshop scheduling and management, participant tracking, and support for different comment types (comments, questions, suggestions, concerns). Features include likes, replies, workshop status tracking, and seamless integration with strategy cards for focused strategic discussions.'
|
||||
},
|
||||
{
|
||||
id: 'sc-4',
|
||||
@@ -325,7 +327,9 @@ const initialFeatures: RoadmapFeature[] = [
|
||||
description: 'Configurable scorecards with standard definitions',
|
||||
category: 'reporting',
|
||||
priority: 'medium',
|
||||
completed: false
|
||||
completed: true,
|
||||
completedDate: new Date().toISOString().split('T')[0],
|
||||
notes: 'Built comprehensive custom scorecard builder allowing users to create multiple scorecards with configurable metrics. Features include metric categorization (Financial, Customer, Internal Process, Learning & Growth, Strategic, Operational, Quality, Safety), weighted scoring system, visibility controls, current vs target tracking with progress visualization, category-based organization, overall scorecard scoring, and real-time metric management with editing capabilities.'
|
||||
},
|
||||
{
|
||||
id: 'rp-5',
|
||||
@@ -333,7 +337,9 @@ const initialFeatures: RoadmapFeature[] = [
|
||||
description: 'Replace manual spreadsheet reporting',
|
||||
category: 'reporting',
|
||||
priority: 'high',
|
||||
completed: false
|
||||
completed: true,
|
||||
completedDate: new Date().toISOString().split('T')[0],
|
||||
notes: 'Implemented comprehensive automated report generation system with multiple pre-configured templates (Executive Summary, Strategic Performance, Operational Dashboard, Financial Performance). Features include configurable sections (executive summary, strategy overview, initiative status, financial summary, KPI dashboard, portfolio breakdown), multiple export formats (HTML, CSV), professional styled HTML reports with tables and charts, automatic data aggregation from all strategic sources, and one-click report generation with download functionality.'
|
||||
},
|
||||
{
|
||||
id: 'nf-1',
|
||||
|
||||
Reference in New Issue
Block a user