- 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
473 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|
|
|