Files
SPARC/frontend/src/pages/Batch.tsx
T
agent-company 153eb3b968 feat: improve loading and error states on Batch and Analytics pages
Analytics page now shows skeleton loaders (cards and chart placeholders)
while data loads, and displays a retry button when the API call fails.
Batch page error state now shows the actual error message and suggests
user action.

Closes leeworks-agents/SPARC#16

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:11:47 +00:00

261 lines
9.8 KiB
TypeScript

import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { analysisApi } from '../api/client';
import { Rocket, CheckCircle, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
import type { BatchAnalysisResult } from '../types';
export function Batch() {
const [companiesInput, setCompaniesInput] = useState('');
const [maxWorkers, setMaxWorkers] = useState(3);
const [result, setResult] = useState<BatchAnalysisResult | null>(null);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const mutation = useMutation({
mutationFn: ({ companies, workers }: { companies: string[]; workers: number }) =>
analysisApi.analyzeBatch(companies, workers),
onSuccess: (data) => setResult(data),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const companies = companiesInput
.split(/[,\n]/)
.map((c) => c.trim())
.filter((c) => c.length > 0);
if (companies.length > 0) {
mutation.mutate({ companies, workers: maxWorkers });
}
};
const toggleExpand = (company: string) => {
const newExpanded = new Set(expandedItems);
if (newExpanded.has(company)) {
newExpanded.delete(company);
} else {
newExpanded.add(company);
}
setExpandedItems(newExpanded);
};
const chartData = result?.results.map((r) => ({
name: r.company_name.toUpperCase(),
patents: r.patent_count,
success: r.success,
}));
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
Batch Company Analysis
</h2>
<p className="text-text-secondary">
Analyze multiple companies simultaneously for comparative insights.
</p>
</div>
{/* Input Form */}
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="md:col-span-2">
<textarea
value={companiesInput}
onChange={(e) => setCompaniesInput(e.target.value)}
placeholder="Enter company names (one per line or comma-separated):&#10;nvidia&#10;amd&#10;intel&#10;qualcomm"
rows={6}
className="w-full bg-bg-card/80 border border-primary/30 rounded-xl px-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all resize-none"
/>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Concurrent Workers
</label>
<input
type="range"
min={1}
max={5}
value={maxWorkers}
onChange={(e) => setMaxWorkers(Number(e.target.value))}
className="w-full accent-primary"
/>
<div className="text-center text-text-primary font-semibold">{maxWorkers}</div>
</div>
<button
type="submit"
disabled={mutation.isPending || !companiesInput.trim()}
className="w-full bg-gradient-to-r from-primary to-primary-dark text-white font-semibold py-3 px-6 rounded-xl hover:shadow-lg hover:shadow-primary/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{mutation.isPending ? (
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
) : (
<>
<Rocket size={18} />
Run Batch Analysis
</>
)}
</button>
</div>
</form>
{/* Progress */}
{mutation.isPending && (
<div className="bg-bg-card/60 border border-primary/15 rounded-xl p-4">
<div className="flex items-center gap-2 text-secondary">
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-secondary"></div>
<span>Analyzing companies...</span>
</div>
</div>
)}
{/* Error */}
{mutation.isError && (
<div className="bg-error/10 border border-error/20 rounded-xl px-4 py-3">
<div className="flex items-center gap-2 text-error">
<AlertCircle size={18} />
<span className="font-semibold">Batch analysis failed</span>
</div>
<p className="text-text-secondary text-sm mt-1 ml-7">
{mutation.error instanceof Error ? mutation.error.message : 'An unexpected error occurred.'}
{' '}Check your connection and try again.
</p>
<button
onClick={() => mutation.reset()}
className="ml-7 mt-2 text-sm text-primary hover:text-primary-dark underline"
>
Dismiss
</button>
</div>
)}
{/* Results */}
{result && (
<div className="space-y-6">
{/* Summary Metrics */}
<div>
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-4">
Results Summary
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<SummaryCard label="Total Companies" value={result.total_companies} />
<SummaryCard label="Successful" value={result.successful} color="success" />
<SummaryCard label="Failed" value={result.failed} color="error" />
<SummaryCard
label="Success Rate"
value={`${Math.round((result.successful / result.total_companies) * 100)}%`}
/>
</div>
</div>
{/* Chart */}
{chartData && chartData.length > 0 && (
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<XAxis dataKey="name" stroke="#94a3b8" fontSize={12} />
<YAxis stroke="#94a3b8" fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid rgba(99, 102, 241, 0.3)',
borderRadius: '8px',
}}
labelStyle={{ color: '#f8fafc' }}
/>
<Bar dataKey="patents" radius={[4, 4, 0, 0]}>
{chartData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.success ? '#10b981' : '#ef4444'}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* Detailed Results */}
<div>
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-4">
Detailed Results
</h3>
<div className="space-y-3">
{result.results.map((r) => (
<div
key={r.company_name}
className="bg-bg-card/60 border border-primary/15 rounded-xl overflow-hidden"
>
<button
onClick={() => toggleExpand(r.company_name)}
className="w-full flex items-center justify-between p-4 hover:bg-bg-card-hover transition-colors"
>
<div className="flex items-center gap-3">
{r.success ? (
<CheckCircle className="text-success" size={20} />
) : (
<AlertCircle className="text-error" size={20} />
)}
<span className="font-semibold text-text-primary">
{r.company_name.toUpperCase()}
</span>
<span className="text-text-secondary">
{r.patent_count} patents
</span>
</div>
{expandedItems.has(r.company_name) ? (
<ChevronUp className="text-text-secondary" size={20} />
) : (
<ChevronDown className="text-text-secondary" size={20} />
)}
</button>
{expandedItems.has(r.company_name) && (
<div className="border-t border-primary/10 p-4 bg-bg-dark/40">
{r.success ? (
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
{r.analysis}
</div>
) : (
<div className="text-error">{r.error}</div>
)}
</div>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}
function SummaryCard({
label,
value,
color,
}: {
label: string;
value: number | string;
color?: 'success' | 'error';
}) {
const colorClass = color === 'success' ? 'text-success' : color === 'error' ? 'text-error' : '';
return (
<div className="bg-gradient-to-br from-primary/10 to-secondary/10 border border-primary/20 rounded-xl p-4 text-center">
<div
className={`text-2xl font-bold ${
colorClass || 'bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent'
}`}
>
{value}
</div>
<div className="text-sm text-text-secondary uppercase tracking-wide mt-1">{label}</div>
</div>
);
}