Files
Sankofa/src/components/infrastructure/CostEstimates.tsx
defiQUG 9daf1fd378 Apply Composer changes: comprehensive API updates, migrations, middleware, and infrastructure improvements
- Add comprehensive database migrations (001-024) for schema evolution
- Enhance API schema with expanded type definitions and resolvers
- Add new middleware: audit logging, rate limiting, MFA enforcement, security, tenant auth
- Implement new services: AI optimization, billing, blockchain, compliance, marketplace
- Add adapter layer for cloud integrations (Cloudflare, Kubernetes, Proxmox, storage)
- Update Crossplane provider with enhanced VM management capabilities
- Add comprehensive test suite for API endpoints and services
- Update frontend components with improved GraphQL subscriptions and real-time updates
- Enhance security configurations and headers (CSP, CORS, etc.)
- Update documentation and configuration files
- Add new CI/CD workflows and validation scripts
- Implement design system improvements and UI enhancements
2025-12-12 18:01:35 -08:00

473 lines
15 KiB
TypeScript

'use client'
import { useState, useMemo } from 'react'
import { useCostEstimates } from '@/lib/hooks/useInfrastructureData'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { RegionSelector } from './RegionSelector'
import { EntitySelector } from './EntitySelector'
import { EditModeToggle } from './EditModeToggle'
import { EditCostEstimateForm } from './forms/EditCostEstimateForm'
import { EmptyState } from './EmptyState'
import { CostForecast } from './lazy'
import { Download } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import ReactECharts from 'echarts-for-react'
import * as XLSX from 'xlsx'
import type { CostEstimate } from '@/lib/types/infrastructure'
function generateCostCharts(estimates: any[]) {
const byRegion = estimates.reduce((acc, e) => {
acc[e.region] = (acc[e.region] || 0) + e.annual
return acc
}, {} as Record<string, number>)
const byCategory = estimates.reduce((acc, e) => {
acc[e.category] = (acc[e.category] || 0) + e.annual
return acc
}, {} as Record<string, number>)
const barChartOption = {
title: {
text: 'Annual Costs by Region',
textStyle: { color: '#e5e7eb' },
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
},
xAxis: {
type: 'category',
data: Object.keys(byRegion),
axisLabel: { color: '#9ca3af' },
},
yAxis: {
type: 'value',
axisLabel: {
color: '#9ca3af',
formatter: (value: number) => `$${(value / 1000000).toFixed(1)}M`,
},
},
series: [
{
data: Object.values(byRegion),
type: 'bar',
itemStyle: { color: '#3b82f6' },
},
],
backgroundColor: 'transparent',
}
const pieChartOption = {
title: {
text: 'Cost Breakdown by Category',
textStyle: { color: '#e5e7eb' },
},
tooltip: {
trigger: 'item',
formatter: '{b}: ${c} ({d}%)',
},
series: [
{
type: 'pie',
radius: '60%',
data: Object.entries(byCategory).map(([name, value]) => ({
value,
name,
})),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
backgroundColor: 'transparent',
}
return { barChartOption, pieChartOption }
}
export function CostEstimates() {
const [selectedRegion, setSelectedRegion] = useState<string>('All')
const [selectedEntity, setSelectedEntity] = useState<string>('All')
const [editMode, setEditMode] = useState(false)
const [editingEstimate, setEditingEstimate] = useState<CostEstimate | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const { toast } = useToast()
const filter = {
region: selectedRegion === 'All' ? undefined : selectedRegion,
entity: selectedEntity === 'All' ? undefined : selectedEntity,
}
const { estimates, loading, error } = useCostEstimates(filter)
const handleExportExcel = () => {
if (estimates.length === 0) {
toast({
title: 'Export failed',
description: 'No data to export',
variant: 'error',
})
return
}
try {
const workbook = XLSX.utils.book_new()
// Summary sheet
const totalMonthly = estimates.reduce((sum, e) => sum + e.monthly, 0)
const totalAnnual = estimates.reduce((sum, e) => sum + e.annual, 0)
const summaryData = [
['Cost Estimates Summary'],
[],
['Total Monthly (USD)', totalMonthly],
['Total Annual (USD)', totalAnnual],
['Number of Estimates', estimates.length],
['Export Date', new Date().toLocaleDateString()],
['Region Filter', selectedRegion],
['Entity Filter', selectedEntity],
]
const summarySheet = XLSX.utils.aoa_to_sheet(summaryData)
XLSX.utils.book_append_sheet(workbook, summarySheet, 'Summary')
// Detailed breakdown sheet
const detailData = estimates.map((e) => ({
Region: e.region,
Entity: e.entity,
Category: e.category,
'Monthly (USD)': e.monthly,
'Annual (USD)': e.annual,
'Compute (USD)': e.breakdown.compute || 0,
'Storage (USD)': e.breakdown.storage || 0,
'Network (USD)': e.breakdown.network || 0,
'Licenses (USD)': e.breakdown.licenses || 0,
'Personnel (USD)': e.breakdown.personnel || 0,
Currency: e.currency || 'USD',
'Last Updated': e.lastUpdated ? new Date(e.lastUpdated).toLocaleDateString() : '',
}))
const detailSheet = XLSX.utils.json_to_sheet(detailData)
// Format currency columns
const range = XLSX.utils.decode_range(detailSheet['!ref'] || 'A1')
for (let C = 3; C <= 10; ++C) {
for (let R = 1; R <= range.e.r; ++R) {
const cellAddress = XLSX.utils.encode_cell({ r: R, c: C })
if (!detailSheet[cellAddress]) continue
detailSheet[cellAddress].z = '$#,##0.00'
}
}
// Set column widths
detailSheet['!cols'] = [
{ wch: 15 }, // Region
{ wch: 20 }, // Entity
{ wch: 15 }, // Category
{ wch: 15 }, // Monthly
{ wch: 15 }, // Annual
{ wch: 15 }, // Compute
{ wch: 15 }, // Storage
{ wch: 15 }, // Network
{ wch: 15 }, // Licenses
{ wch: 15 }, // Personnel
{ wch: 10 }, // Currency
{ wch: 12 }, // Last Updated
]
XLSX.utils.book_append_sheet(workbook, detailSheet, 'Detailed Breakdown')
// By region sheet
const byRegion = estimates.reduce((acc, e) => {
acc[e.region] = (acc[e.region] || 0) + e.annual
return acc
}, {} as Record<string, number>)
const regionData = Object.entries(byRegion)
.sort(([, a], [, b]) => b - a)
.map(([region, annual]) => ({
Region: region,
'Annual Cost (USD)': annual,
'Monthly Cost (USD)': annual / 12,
}))
const regionSheet = XLSX.utils.json_to_sheet(regionData)
// Format currency columns
const regionRange = XLSX.utils.decode_range(regionSheet['!ref'] || 'A1')
for (let C = 1; C <= 2; ++C) {
for (let R = 1; R <= regionRange.e.r; ++R) {
const cellAddress = XLSX.utils.encode_cell({ r: R, c: C })
if (!regionSheet[cellAddress] || R === 0) continue
regionSheet[cellAddress].z = '$#,##0.00'
}
}
XLSX.utils.book_append_sheet(workbook, regionSheet, 'By Region')
// By category sheet
const byCategory = estimates.reduce((acc, e) => {
acc[e.category] = (acc[e.category] || 0) + e.annual
return acc
}, {} as Record<string, number>)
const categoryData = Object.entries(byCategory)
.sort(([, a], [, b]) => b - a)
.map(([category, annual]) => ({
Category: category,
'Annual Cost (USD)': annual,
'Monthly Cost (USD)': annual / 12,
}))
const categorySheet = XLSX.utils.json_to_sheet(categoryData)
// Format currency columns
const categoryRange = XLSX.utils.decode_range(categorySheet['!ref'] || 'A1')
for (let C = 1; C <= 2; ++C) {
for (let R = 1; R <= categoryRange.e.r; ++R) {
const cellAddress = XLSX.utils.encode_cell({ r: R, c: C })
if (!categorySheet[cellAddress] || R === 0) continue
categorySheet[cellAddress].z = '$#,##0.00'
}
}
XLSX.utils.book_append_sheet(workbook, categorySheet, 'By Category')
// Save workbook
XLSX.writeFile(
workbook,
`cost-estimates-${selectedRegion}-${selectedEntity}-${Date.now()}.xlsx`
)
toast({
title: 'Export successful',
description: 'Cost estimates exported as Excel',
})
} catch (error) {
toast({
title: 'Export failed',
description: error instanceof Error ? error.message : 'Unknown error occurred',
variant: 'error',
})
}
}
const totalAnnual = useMemo(
() => estimates.reduce((sum, e) => sum + e.annual, 0),
[estimates]
)
const totalMonthly = useMemo(
() => estimates.reduce((sum, e) => sum + e.monthly, 0),
[estimates]
)
const { barChartOption, pieChartOption } = useMemo(
() => generateCostCharts(estimates),
[estimates]
)
if (loading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-studio-light">Cost Estimates</h1>
<p className="text-studio-medium mt-2">
View and manage cost estimates by region, entity, and category
</p>
</div>
<Card>
<CardContent>
<div className="h-[400px] flex items-center justify-center">
<div className="text-studio-medium">Loading cost estimates...</div>
</div>
</CardContent>
</Card>
</div>
)
}
if (error) {
return (
<Card>
<CardHeader>
<CardTitle>Error Loading Cost Estimates</CardTitle>
<CardDescription>{error.message}</CardDescription>
</CardHeader>
</Card>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-studio-light">Cost Estimates</h1>
<p className="text-studio-medium mt-2">
View and manage cost estimates by region, entity, and category
</p>
</div>
<Button variant="outline" onClick={handleExportExcel} disabled={!estimates.length}>
<Download className="h-4 w-4 mr-2" />
Export Excel
</Button>
</div>
<div className="grid gap-6 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Total Monthly</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-studio-light">
${(totalMonthly / 1000).toFixed(0)}K
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Total Annual</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-studio-light">
${(totalAnnual / 1000000).toFixed(1)}M
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Estimates</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-studio-light">{estimates.length}</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Cost Visualizations</CardTitle>
<CardDescription>Charts showing cost breakdown</CardDescription>
</div>
<div className="flex items-center gap-4">
<RegionSelector
value={selectedRegion}
onChange={setSelectedRegion}
className="w-48"
/>
<EntitySelector
value={selectedEntity}
onChange={setSelectedEntity}
className="w-64"
/>
<EditModeToggle enabled={editMode} onToggle={setEditMode} />
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2">
<div>
<ReactECharts option={barChartOption} style={{ height: '400px' }} />
</div>
<div>
<ReactECharts option={pieChartOption} style={{ height: '400px' }} />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Detailed Breakdown</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Region</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Category</TableHead>
<TableHead>Monthly</TableHead>
<TableHead>Annual</TableHead>
<TableHead>Breakdown</TableHead>
{editMode && <TableHead>Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{estimates.length === 0 ? (
<TableRow>
<TableCell colSpan={editMode ? 7 : 6} className="text-center">
<EmptyState
title="No cost estimates found"
description="No cost estimates match your current filters."
/>
</TableCell>
</TableRow>
) : (
estimates.map((estimate, idx) => (
<TableRow key={idx}>
<TableCell className="font-medium">{estimate.region}</TableCell>
<TableCell>{estimate.entity}</TableCell>
<TableCell>{estimate.category}</TableCell>
<TableCell>${(estimate.monthly / 1000).toFixed(0)}K</TableCell>
<TableCell>${(estimate.annual / 1000).toFixed(0)}K</TableCell>
<TableCell>
<div className="text-sm text-studio-medium">
Compute: ${((estimate.breakdown.compute || 0) / 1000).toFixed(0)}K
<br />
Storage: ${((estimate.breakdown.storage || 0) / 1000).toFixed(0)}K
<br />
Network: ${((estimate.breakdown.network || 0) / 1000).toFixed(0)}K
</div>
</TableCell>
{editMode && (
<TableCell>
<Button
size="sm"
variant="outline"
onClick={() => {
setEditingEstimate(estimate)
setDialogOpen(true)
}}
>
Edit
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{estimates.length > 0 && (
<CostForecast estimates={estimates} months={12} />
)}
{editingEstimate && (
<EditCostEstimateForm
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) setEditingEstimate(null)
}}
estimate={editingEstimate}
onSuccess={() => {
setEditingEstimate(null)
}}
/>
)}
</div>
)
}