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