Files
mockupAWS/frontend/src/pages/ScenariosPage.tsx
T
Luca Sacchi Ricciardi a5fc85897b
E2E Tests / Run E2E Tests (push) Has been cancelled
E2E Tests / Visual Regression Tests (push) Has been cancelled
E2E Tests / Smoke Tests (push) Has been cancelled
feat: implement v0.4.0 - Reports, Charts, Comparison, Dark Mode, E2E Testing
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
2026-04-07 16:11:47 +02:00

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>
);
}