a5fc85897b
Backend (@backend-dev): - Add ReportService with PDF/CSV generation (reportlab, pandas) - Implement Report API endpoints (POST, GET, DELETE, download) - Add ReportRepository and schemas - Configure storage with auto-cleanup (30 days) - Rate limiting: 10 downloads/minute - Professional PDF templates with charts support Frontend (@frontend-dev): - Integrate Recharts for data visualization - Add CostBreakdown, TimeSeries, ComparisonBar charts - Implement scenario comparison page with multi-select - Add dark/light mode toggle with ThemeProvider - Create Reports page with generation form and list - Add new UI components: checkbox, dialog, tabs, label, skeleton - Implement useComparison and useReports hooks QA (@qa-engineer): - Setup Playwright E2E testing framework - Create 7 test spec files with 94 test cases - Add visual regression testing with baselines - Configure multi-browser testing (Chrome, Firefox, WebKit) - Add mobile responsive tests - Create test fixtures and helpers - Setup GitHub Actions CI workflow Documentation (@spec-architect): - Create detailed kanban-v0.4.0.md with 27 tasks - Update progress.md with v0.4.0 tracking - Create v0.4.0 planning prompt Features: ✅ PDF/CSV Report Generation ✅ Interactive Charts (Pie, Area, Bar) ✅ Scenario Comparison (2-4 scenarios) ✅ Dark/Light Mode Toggle ✅ E2E Test Suite (94 tests) Dependencies added: - Backend: reportlab, pandas, slowapi - Frontend: recharts, date-fns, @radix-ui/react-checkbox/dialog/tabs - Testing: @playwright/test 27 tasks completed, 100% v0.4.0 implementation
302 lines
9.8 KiB
TypeScript
302 lines
9.8 KiB
TypeScript
import { useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
useScenarios,
|
|
useStartScenario,
|
|
useStopScenario,
|
|
useDeleteScenario
|
|
} from '@/hooks/useScenarios';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow
|
|
} from '@/components/ui/table';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger
|
|
} from '@/components/ui/dropdown-menu';
|
|
import {
|
|
MoreHorizontal,
|
|
Play,
|
|
Square,
|
|
Trash2,
|
|
BarChart3,
|
|
X,
|
|
FileText,
|
|
} from 'lucide-react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
|
|
const statusColors = {
|
|
draft: 'secondary',
|
|
running: 'default',
|
|
completed: 'outline',
|
|
archived: 'destructive',
|
|
} as const;
|
|
|
|
export function ScenariosPage() {
|
|
const navigate = useNavigate();
|
|
const { data: scenarios, isLoading } = useScenarios();
|
|
const [selectedScenarios, setSelectedScenarios] = useState<Set<string>>(new Set());
|
|
const [showCompareModal, setShowCompareModal] = useState(false);
|
|
|
|
const startScenario = useStartScenario('');
|
|
const stopScenario = useStopScenario('');
|
|
const deleteScenario = useDeleteScenario();
|
|
|
|
const toggleScenario = (id: string, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setSelectedScenarios((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) {
|
|
next.delete(id);
|
|
} else if (next.size < 4) {
|
|
next.add(id);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const toggleAll = () => {
|
|
if (selectedScenarios.size > 0) {
|
|
setSelectedScenarios(new Set());
|
|
} else if (scenarios?.items) {
|
|
const firstFour = scenarios.items.slice(0, 4).map((s) => s.id);
|
|
setSelectedScenarios(new Set(firstFour));
|
|
}
|
|
};
|
|
|
|
const clearSelection = () => {
|
|
setSelectedScenarios(new Set());
|
|
};
|
|
|
|
const handleCompare = () => {
|
|
setShowCompareModal(true);
|
|
};
|
|
|
|
const confirmCompare = () => {
|
|
const ids = Array.from(selectedScenarios);
|
|
navigate('/compare', { state: { scenarioIds: ids } });
|
|
};
|
|
|
|
const handleStart = (_id: string, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
startScenario.mutate();
|
|
};
|
|
|
|
const handleStop = (_id: string, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
stopScenario.mutate();
|
|
};
|
|
|
|
const handleDelete = (id: string, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (confirm('Are you sure you want to delete this scenario?')) {
|
|
deleteScenario.mutate(id);
|
|
}
|
|
};
|
|
|
|
const canCompare = selectedScenarios.size >= 2 && selectedScenarios.size <= 4;
|
|
|
|
if (isLoading) {
|
|
return <div>Loading...</div>;
|
|
}
|
|
|
|
const selectedScenarioData = scenarios?.items.filter((s) => selectedScenarios.has(s.id));
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Scenarios</h1>
|
|
<p className="text-muted-foreground">
|
|
Manage your AWS cost simulation scenarios
|
|
</p>
|
|
</div>
|
|
{selectedScenarios.size > 0 && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-muted-foreground">
|
|
{selectedScenarios.size} selected
|
|
</span>
|
|
<Button variant="ghost" size="sm" onClick={clearSelection}>
|
|
<X className="h-4 w-4 mr-1" />
|
|
Clear
|
|
</Button>
|
|
<Button
|
|
onClick={handleCompare}
|
|
disabled={!canCompare}
|
|
size="sm"
|
|
>
|
|
<BarChart3 className="mr-2 h-4 w-4" />
|
|
Compare Selected
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Selection Mode Indicator */}
|
|
{selectedScenarios.size > 0 && (
|
|
<div className="bg-muted/50 rounded-lg p-3 flex items-center gap-4">
|
|
<span className="text-sm font-medium">
|
|
Comparison Mode: Select 2-4 scenarios
|
|
</span>
|
|
<div className="flex gap-2">
|
|
{selectedScenarioData?.map((s) => (
|
|
<Badge key={s.id} variant="secondary" className="gap-1">
|
|
{s.name}
|
|
<X
|
|
className="h-3 w-3 cursor-pointer"
|
|
onClick={() => setSelectedScenarios((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(s.id);
|
|
return next;
|
|
})}
|
|
/>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[50px]">
|
|
<Checkbox
|
|
checked={selectedScenarios.size > 0 && selectedScenarios.size === (scenarios?.items.length || 0)}
|
|
onCheckedChange={toggleAll}
|
|
aria-label="Select all"
|
|
/>
|
|
</TableHead>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Region</TableHead>
|
|
<TableHead>Requests</TableHead>
|
|
<TableHead>Cost</TableHead>
|
|
<TableHead className="w-[120px]">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{scenarios?.items.map((scenario) => (
|
|
<TableRow
|
|
key={scenario.id}
|
|
className="cursor-pointer hover:bg-muted/50"
|
|
onClick={() => navigate(`/scenarios/${scenario.id}`)}
|
|
>
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox
|
|
checked={selectedScenarios.has(scenario.id)}
|
|
onCheckedChange={() => {}}
|
|
onClick={(e: React.MouseEvent) => toggleScenario(scenario.id, e)}
|
|
aria-label={`Select ${scenario.name}`}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="font-medium">{scenario.name}</TableCell>
|
|
<TableCell>
|
|
<Badge variant={statusColors[scenario.status]}>
|
|
{scenario.status}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>{scenario.region}</TableCell>
|
|
<TableCell>{scenario.total_requests.toLocaleString()}</TableCell>
|
|
<TableCell>${scenario.total_cost_estimate.toFixed(6)}</TableCell>
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
navigate(`/scenarios/${scenario.id}/reports`);
|
|
}}
|
|
title="Reports"
|
|
>
|
|
<FileText className="h-4 w-4" />
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
{scenario.status === 'draft' && (
|
|
<DropdownMenuItem onClick={(e) => handleStart(scenario.id, e as React.MouseEvent)}>
|
|
<Play className="mr-2 h-4 w-4" />
|
|
Start
|
|
</DropdownMenuItem>
|
|
)}
|
|
{scenario.status === 'running' && (
|
|
<DropdownMenuItem onClick={(e) => handleStop(scenario.id, e as React.MouseEvent)}>
|
|
<Square className="mr-2 h-4 w-4" />
|
|
Stop
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem
|
|
className="text-destructive"
|
|
onClick={(e) => handleDelete(scenario.id, e as React.MouseEvent)}
|
|
>
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
{/* Compare Confirmation Modal */}
|
|
<Dialog open={showCompareModal} onOpenChange={setShowCompareModal}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Compare Scenarios</DialogTitle>
|
|
<DialogDescription>
|
|
You are about to compare {selectedScenarios.size} scenarios side by side.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="py-4">
|
|
<p className="text-sm font-medium mb-2">Selected scenarios:</p>
|
|
<ul className="space-y-2">
|
|
{selectedScenarioData?.map((s, i) => (
|
|
<li key={s.id} className="flex items-center gap-2 text-sm">
|
|
<span className="text-muted-foreground">{i + 1}.</span>
|
|
<span className="font-medium">{s.name}</span>
|
|
<Badge variant="secondary" className="text-xs">{s.region}</Badge>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowCompareModal(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={confirmCompare}>
|
|
<BarChart3 className="mr-2 h-4 w-4" />
|
|
Start Comparison
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|