feat(dashboard): add review list and detail pages
This commit is contained in:
101
dashboard/src/components/reviews/FindingCard.tsx
Normal file
101
dashboard/src/components/reviews/FindingCard.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { Finding } from '../../types/api';
|
||||||
|
import { SeverityBadge } from '../common/SeverityBadge';
|
||||||
|
|
||||||
|
interface FindingCardProps {
|
||||||
|
finding: Finding;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentColors: Record<string, string> = {
|
||||||
|
security: 'text-red-600 bg-red-50',
|
||||||
|
style: 'text-blue-600 bg-blue-50',
|
||||||
|
complexity: 'text-purple-600 bg-purple-50',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FindingCard({ finding }: FindingCardProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full px-4 py-3 flex items-start gap-4 text-left hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
<SeverityBadge severity={finding.severity} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
{finding.title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-1.5 py-0.5 rounded ${agentColors[finding.agent] || 'text-gray-600 bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
{finding.agent}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||||
|
<span className="font-mono">
|
||||||
|
{finding.file}:{finding.line_start}
|
||||||
|
{finding.line_end !== finding.line_start &&
|
||||||
|
`-${finding.line_end}`}
|
||||||
|
</span>
|
||||||
|
<span>{Math.round(finding.confidence * 100)}% confidence</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<span className="text-gray-400">{isExpanded ? '▼' : '▶'}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 py-3 bg-gray-50 border-t border-gray-200 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">
|
||||||
|
Description
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-700">{finding.description}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">
|
||||||
|
Reasoning
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-700">{finding.reasoning}</p>
|
||||||
|
</div>
|
||||||
|
{finding.suggestion && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">
|
||||||
|
Suggestion
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-700">{finding.suggestion}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{finding.references.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">
|
||||||
|
References
|
||||||
|
</h4>
|
||||||
|
<ul className="text-sm space-y-1">
|
||||||
|
{finding.references.map((ref, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
<a
|
||||||
|
href={ref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-indigo-600 hover:text-indigo-800 hover:underline"
|
||||||
|
>
|
||||||
|
{ref}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
Prompt version: {finding.prompt_version}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
dashboard/src/components/reviews/ReviewCard.tsx
Normal file
69
dashboard/src/components/reviews/ReviewCard.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { ReviewSummary } from '../../types/api';
|
||||||
|
import { VerdictBadge } from '../deliberation/VerdictBadge';
|
||||||
|
|
||||||
|
interface ReviewCardProps {
|
||||||
|
review: ReviewSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCost(cost: number | null): string {
|
||||||
|
if (cost === null) return '-';
|
||||||
|
return `$${cost.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewCard({ review }: ReviewCardProps) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/reviews/${review.id}`}
|
||||||
|
className="block bg-white border border-gray-200 rounded-lg p-4 hover:border-indigo-300 hover:shadow-sm transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{review.repository}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">#{review.pr_number}</span>
|
||||||
|
</div>
|
||||||
|
{review.pr_title && (
|
||||||
|
<p className="text-sm text-gray-600 truncate">{review.pr_title}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||||
|
{review.author && <span>by {review.author}</span>}
|
||||||
|
<span>{formatDate(review.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<VerdictBadge verdict={review.verdict} size="sm" />
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{review.finding_count} findings
|
||||||
|
</span>
|
||||||
|
{review.critical_count > 0 && (
|
||||||
|
<span className="text-red-600 font-medium">
|
||||||
|
{review.critical_count} critical
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{review.high_count > 0 && (
|
||||||
|
<span className="text-orange-600 font-medium">
|
||||||
|
{review.high_count} high
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{formatCost(review.total_cost_usd)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
dashboard/src/components/reviews/ReviewFilters.tsx
Normal file
110
dashboard/src/components/reviews/ReviewFilters.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { ReviewStatus, Verdict, ReviewFilters as Filters } from '../../types/api';
|
||||||
|
|
||||||
|
interface ReviewFiltersProps {
|
||||||
|
filters: Filters;
|
||||||
|
onChange: (filters: Filters) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusOptions: { value: ReviewStatus | ''; label: string }[] = [
|
||||||
|
{ value: '', label: 'All Statuses' },
|
||||||
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
{ value: 'running', label: 'Running' },
|
||||||
|
{ value: 'completed', label: 'Completed' },
|
||||||
|
{ value: 'failed', label: 'Failed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const verdictOptions: { value: Verdict | ''; label: string }[] = [
|
||||||
|
{ value: '', label: 'All Verdicts' },
|
||||||
|
{ value: 'approve', label: 'Approved' },
|
||||||
|
{ value: 'request_changes', label: 'Changes Requested' },
|
||||||
|
{ value: 'comment', label: 'Comment' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ReviewFilters({ filters, onChange }: ReviewFiltersProps) {
|
||||||
|
const handleChange = (key: keyof Filters, value: string) => {
|
||||||
|
onChange({
|
||||||
|
...filters,
|
||||||
|
[key]: value || undefined,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="repository"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Repository
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="repository"
|
||||||
|
value={filters.repository || ''}
|
||||||
|
onChange={(e) => handleChange('repository', e.target.value)}
|
||||||
|
placeholder="Filter by repository..."
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="author"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Author
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="author"
|
||||||
|
value={filters.author || ''}
|
||||||
|
onChange={(e) => handleChange('author', e.target.value)}
|
||||||
|
placeholder="Filter by author..."
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="status"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="status"
|
||||||
|
value={filters.status || ''}
|
||||||
|
onChange={(e) => handleChange('status', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="verdict"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Verdict
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="verdict"
|
||||||
|
value={filters.verdict || ''}
|
||||||
|
onChange={(e) => handleChange('verdict', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
{verdictOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
dashboard/src/components/reviews/ReviewList.tsx
Normal file
47
dashboard/src/components/reviews/ReviewList.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { ReviewSummary } from '../../types/api';
|
||||||
|
import { ReviewCard } from './ReviewCard';
|
||||||
|
import { LoadingSpinner } from '../common/LoadingSpinner';
|
||||||
|
|
||||||
|
interface ReviewListProps {
|
||||||
|
reviews: ReviewSummary[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewList({ reviews, isLoading, error }: ReviewListProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
|
||||||
|
<p className="text-red-800">Failed to load reviews</p>
|
||||||
|
<p className="text-sm text-red-600 mt-1">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reviews.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
|
||||||
|
<p className="text-gray-600">No reviews found</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Try adjusting your filters or check back later
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{reviews.map((review) => (
|
||||||
|
<ReviewCard key={review.id} review={review} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
dashboard/src/pages/ReviewDetailPage.tsx
Normal file
214
dashboard/src/pages/ReviewDetailPage.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { useReview, useDeliberationLog } from '../api/reviews';
|
||||||
|
import { VerdictBadge } from '../components/deliberation/VerdictBadge';
|
||||||
|
import { FindingCard } from '../components/reviews/FindingCard';
|
||||||
|
import { ConflictCard } from '../components/deliberation/ConflictCard';
|
||||||
|
import { DeliberationTimeline } from '../components/deliberation/DeliberationTimeline';
|
||||||
|
import { LoadingSpinner } from '../components/common/LoadingSpinner';
|
||||||
|
import type { Severity } from '../types/api';
|
||||||
|
|
||||||
|
const severityOrder: Severity[] = ['critical', 'high', 'medium', 'low', 'info'];
|
||||||
|
|
||||||
|
function formatDate(dateString: string | null): string {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
return new Date(dateString).toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCost(cost: number | null): string {
|
||||||
|
if (cost === null) return '-';
|
||||||
|
return `$${cost.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { data: review, isLoading, error } = useReview(id!);
|
||||||
|
const { data: deliberation } = useDeliberationLog(id!);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !review) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
|
||||||
|
<p className="text-red-800">Failed to load review</p>
|
||||||
|
<p className="text-sm text-red-600 mt-1">{error?.message}</p>
|
||||||
|
<Link to="/" className="text-indigo-600 hover:underline mt-2 inline-block">
|
||||||
|
Back to reviews
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const findingsBySeverity = severityOrder.reduce(
|
||||||
|
(acc, severity) => {
|
||||||
|
const findings = review.findings.filter((f) => f.severity === severity);
|
||||||
|
if (findings.length > 0) {
|
||||||
|
acc[severity] = findings;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<Severity, typeof review.findings>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-sm text-indigo-600 hover:text-indigo-800 mb-2 inline-block"
|
||||||
|
>
|
||||||
|
← Back to reviews
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{review.repository} #{review.pr_number}
|
||||||
|
</h1>
|
||||||
|
{review.pr_title && (
|
||||||
|
<p className="text-gray-600 mt-1">{review.pr_title}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<VerdictBadge
|
||||||
|
verdict={review.verdict}
|
||||||
|
confidence={review.verdict_confidence}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta Info */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-6">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Author</span>
|
||||||
|
<p className="font-medium">{review.author || '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Status</span>
|
||||||
|
<p className="font-medium capitalize">{review.status}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Created</span>
|
||||||
|
<p className="font-medium">{formatDate(review.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Completed</span>
|
||||||
|
<p className="font-medium">{formatDate(review.completed_at)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm mt-4 pt-4 border-t border-gray-100">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Total Cost</span>
|
||||||
|
<p className="font-medium">{formatCost(review.total_cost_usd)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Total Tokens</span>
|
||||||
|
<p className="font-medium">
|
||||||
|
{review.total_tokens?.toLocaleString() || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Base SHA</span>
|
||||||
|
<p className="font-mono text-xs">{review.base_sha.slice(0, 7)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Head SHA</span>
|
||||||
|
<p className="font-mono text-xs">{review.head_sha.slice(0, 7)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{review.cost_by_agent && Object.keys(review.cost_by_agent).length > 0 && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||||
|
<span className="text-gray-500 text-sm">Cost by Agent</span>
|
||||||
|
<div className="flex gap-4 mt-1">
|
||||||
|
{Object.entries(review.cost_by_agent).map(([agent, cost]) => (
|
||||||
|
<span key={agent} className="text-sm">
|
||||||
|
<span className="font-medium capitalize">{agent}:</span>{' '}
|
||||||
|
{formatCost(cost)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Verdict Reasoning */}
|
||||||
|
{review.verdict_reasoning && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
Verdict Reasoning
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700">{review.verdict_reasoning}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{review.error_message && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-red-800 mb-2">Error</h2>
|
||||||
|
<p className="text-red-700">{review.error_message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Findings */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Findings ({review.findings.length})
|
||||||
|
</h2>
|
||||||
|
{review.findings.length === 0 ? (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-center">
|
||||||
|
<p className="text-green-800">No findings reported</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(findingsBySeverity).map(([severity, findings]) => (
|
||||||
|
<div key={severity}>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-2 capitalize">
|
||||||
|
{severity} ({findings.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{findings.map((finding) => (
|
||||||
|
<FindingCard key={finding.id} finding={finding} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conflicts */}
|
||||||
|
{review.conflicts.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Conflicts ({review.conflicts.length})
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{review.conflicts.map((conflict) => (
|
||||||
|
<ConflictCard key={conflict.id} conflict={conflict} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deliberation Timeline */}
|
||||||
|
{deliberation && deliberation.steps.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Deliberation Process
|
||||||
|
</h2>
|
||||||
|
<DeliberationTimeline steps={deliberation.steps} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
dashboard/src/pages/ReviewsPage.tsx
Normal file
46
dashboard/src/pages/ReviewsPage.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useReviews } from '../api/reviews';
|
||||||
|
import { ReviewList } from '../components/reviews/ReviewList';
|
||||||
|
import { ReviewFilters } from '../components/reviews/ReviewFilters';
|
||||||
|
import { Pagination } from '../components/common/Pagination';
|
||||||
|
import type { ReviewFilters as Filters } from '../types/api';
|
||||||
|
|
||||||
|
export function ReviewsPage() {
|
||||||
|
const [filters, setFilters] = useState<Filters>({
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useReviews(filters);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Reviews</h1>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
View and explore code review results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReviewFilters filters={filters} onChange={setFilters} />
|
||||||
|
|
||||||
|
<ReviewList
|
||||||
|
reviews={data?.items || []}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{data && data.pages > 1 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Pagination
|
||||||
|
page={data.page}
|
||||||
|
pages={data.pages}
|
||||||
|
total={data.total}
|
||||||
|
pageSize={data.page_size}
|
||||||
|
onPageChange={(page) => setFilters((prev) => ({ ...prev, page }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user