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
This commit is contained in:
@@ -1,11 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useScenarios, useStartScenario, useStopScenario, useDeleteScenario } from '@/hooks/useScenarios';
|
||||
import {
|
||||
useScenarios,
|
||||
useStartScenario,
|
||||
useStopScenario,
|
||||
useDeleteScenario
|
||||
} from '@/hooks/useScenarios';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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 } from 'lucide-react';
|
||||
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',
|
||||
@@ -17,13 +51,76 @@ const statusColors = {
|
||||
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>
|
||||
@@ -31,26 +128,84 @@ export function ScenariosPage() {
|
||||
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-[100px]">Actions</TableHead>
|
||||
<TableHead className="w-[120px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{scenarios?.items.map((scenario) => (
|
||||
<TableRow
|
||||
key={scenario.id}
|
||||
className="cursor-pointer"
|
||||
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]}>
|
||||
@@ -58,39 +213,89 @@ export function ScenariosPage() {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{scenario.region}</TableCell>
|
||||
<TableCell>{scenario.total_requests}</TableCell>
|
||||
<TableCell>{scenario.total_requests.toLocaleString()}</TableCell>
|
||||
<TableCell>${scenario.total_cost_estimate.toFixed(6)}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{scenario.status === 'draft' && (
|
||||
<DropdownMenuItem>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start
|
||||
<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>
|
||||
)}
|
||||
{scenario.status === 'running' && (
|
||||
<DropdownMenuItem>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
Stop
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user