mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-15 02:42:45 +08:00
feat: add CLI Viewer Page with multi-pane layout and state management
- Implemented the CliViewerPage component for displaying CLI outputs in a configurable multi-pane layout. - Integrated Zustand for state management, allowing for dynamic layout changes and tab management. - Added layout options: single, split horizontal, split vertical, and 2x2 grid. - Created viewerStore for managing layout, panes, and tabs, including actions for adding/removing panes and tabs. - Added CoordinatorPage barrel export for easier imports.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// ========================================
|
||||
// ReviewSessionPage Component
|
||||
// ========================================
|
||||
// Review session detail page with findings display and multi-select
|
||||
// Review session detail page with findings display, multi-select, dimension tabs, and fix progress carousel
|
||||
|
||||
import * as React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
Download,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronLeft as ChevronLeftIcon,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
} from 'lucide-react';
|
||||
import { useReviewSession } from '@/hooks/useReviewSession';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -42,6 +44,229 @@ interface FindingWithSelection {
|
||||
impact?: string;
|
||||
}
|
||||
|
||||
// Fix Progress Types
|
||||
interface FixStage {
|
||||
stage: number;
|
||||
status: 'completed' | 'in-progress' | 'pending';
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
interface FixProgressData {
|
||||
fix_session_id: string;
|
||||
phase: 'planning' | 'execution' | 'completion';
|
||||
total_findings: number;
|
||||
fixed_count: number;
|
||||
failed_count: number;
|
||||
in_progress_count: number;
|
||||
pending_count: number;
|
||||
percent_complete: number;
|
||||
current_stage: number;
|
||||
total_stages: number;
|
||||
stages: FixStage[];
|
||||
active_agents: Array<{
|
||||
agent_id: string;
|
||||
group_id: string;
|
||||
current_finding: { finding_title: string } | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix Progress Carousel Component
|
||||
* Displays fix progress with polling and carousel navigation
|
||||
*/
|
||||
function FixProgressCarousel({ sessionId }: { sessionId: string }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [fixProgressData, setFixProgressData] = React.useState<FixProgressData | null>(null);
|
||||
const [currentSlide, setCurrentSlide] = React.useState(0);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
// Fetch fix progress data
|
||||
const fetchFixProgress = React.useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/fix-progress?sessionId=${encodeURIComponent(sessionId)}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setFixProgressData(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
setFixProgressData(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch fix progress:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
// Poll for fix progress updates
|
||||
React.useEffect(() => {
|
||||
fetchFixProgress();
|
||||
|
||||
// Stop polling if phase is completion
|
||||
if (fixProgressData?.phase === 'completion') {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchFixProgress();
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchFixProgress, fixProgressData?.phase]);
|
||||
|
||||
// Navigate carousel
|
||||
const navigateSlide = (direction: 'prev' | 'next' | number) => {
|
||||
if (!fixProgressData) return;
|
||||
|
||||
const totalSlides = fixProgressData.active_agents.length > 0 ? 3 : 2;
|
||||
if (typeof direction === 'number') {
|
||||
setCurrentSlide(direction);
|
||||
} else if (direction === 'next') {
|
||||
setCurrentSlide((prev) => (prev + 1) % totalSlides);
|
||||
} else if (direction === 'prev') {
|
||||
setCurrentSlide((prev) => (prev - 1 + totalSlides) % totalSlides);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && !fixProgressData) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="h-32 bg-muted animate-pulse rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!fixProgressData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { phase, total_findings, fixed_count, failed_count, in_progress_count, pending_count, percent_complete, current_stage, total_stages, stages, active_agents } = fixProgressData;
|
||||
|
||||
const phaseIcon = phase === 'planning' ? '📝' : phase === 'execution' ? '⚡' : '✅';
|
||||
const totalSlides = active_agents.length > 0 ? 3 : 2;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🔧</span>
|
||||
<span className="font-semibold text-sm">{formatMessage({ id: 'reviewSession.fixProgress.title' })}</span>
|
||||
</div>
|
||||
{/* Stage Dots */}
|
||||
<div className="flex gap-1">
|
||||
{stages.map((stage, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
stage.status === 'completed' ? 'bg-green-500' :
|
||||
stage.status === 'in-progress' ? 'bg-blue-500' :
|
||||
'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
title={`Stage ${i + 1}: ${stage.status}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carousel */}
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
className="flex transition-transform duration-300 ease-in-out"
|
||||
style={{ transform: `translateX(-${currentSlide * 100}%)` }}
|
||||
>
|
||||
{/* Slide 1: Overview */}
|
||||
<div className="w-full flex-shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge variant={phase === 'planning' ? 'secondary' : phase === 'execution' ? 'default' : 'success'}>
|
||||
{phaseIcon} {formatMessage({ id: `reviewSession.fixProgress.phase.${phase}` })}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">{fixProgressData.fix_session_id}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${percent_complete}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{formatMessage({ id: 'reviewSession.fixProgress.complete' }, { percent: percent_complete.toFixed(0) })} · {formatMessage({ id: 'reviewSession.fixProgress.stage' })} {current_stage}/{total_stages}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slide 2: Stats */}
|
||||
<div className="w-full flex-shrink-0">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="text-center p-2 bg-muted rounded">
|
||||
<div className="text-lg font-bold">{total_findings}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.fixProgress.stats.total' })}</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-green-100 dark:bg-green-900/20 rounded">
|
||||
<div className="text-lg font-bold text-green-600 dark:text-green-400">{fixed_count}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.fixProgress.stats.fixed' })}</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-red-100 dark:bg-red-900/20 rounded">
|
||||
<div className="text-lg font-bold text-red-600 dark:text-red-400">{failed_count}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.fixProgress.stats.failed' })}</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-yellow-100 dark:bg-yellow-900/20 rounded">
|
||||
<div className="text-lg font-bold text-yellow-600 dark:text-yellow-400">{pending_count + in_progress_count}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.fixProgress.stats.pending' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slide 3: Active Agents (if any) */}
|
||||
{active_agents.length > 0 && (
|
||||
<div className="w-full flex-shrink-0">
|
||||
<div className="text-sm font-semibold mb-2">
|
||||
{active_agents.length} {active_agents.length === 1 ? formatMessage({ id: 'reviewSession.fixProgress.activeAgents' }) : formatMessage({ id: 'reviewSession.fixProgress.activeAgentsPlural' })}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{active_agents.slice(0, 2).map((agent, i) => (
|
||||
<div key={i} className="flex items-center gap-2 p-2 bg-muted rounded">
|
||||
<span>🤖</span>
|
||||
<span className="text-sm">{agent.current_finding?.finding_title || formatMessage({ id: 'reviewSession.fixProgress.working' })}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carousel Navigation */}
|
||||
{totalSlides > 1 && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => navigateSlide('prev')}>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: totalSlides }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`w-2 h-2 rounded-full transition-colors ${
|
||||
currentSlide === i ? 'bg-primary' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
onClick={() => navigateSlide(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => navigateSlide('next')}>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ReviewSessionPage component - Display review session findings
|
||||
*/
|
||||
@@ -61,11 +286,13 @@ export function ReviewSessionPage() {
|
||||
const [severityFilter, setSeverityFilter] = React.useState<Set<SeverityFilter>>(
|
||||
new Set(['critical', 'high', 'medium', 'low'])
|
||||
);
|
||||
const [dimensionFilter, setDimensionFilter] = React.useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [sortField, setSortField] = React.useState<SortField>('severity');
|
||||
const [sortOrder, setSortOrder] = React.useState<SortOrder>('desc');
|
||||
const [selectedFindings, setSelectedFindings] = React.useState<Set<string>>(new Set());
|
||||
const [expandedFindings, setExpandedFindings] = React.useState<Set<string>>(new Set());
|
||||
const [selectedFindingId, setSelectedFindingId] = React.useState<string | null>(null);
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/sessions');
|
||||
@@ -83,6 +310,12 @@ export function ReviewSessionPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setSeverityFilter(new Set(['critical', 'high', 'medium', 'low']));
|
||||
setDimensionFilter('all');
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const toggleSelectFinding = (findingId: string) => {
|
||||
setSelectedFindings(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -104,6 +337,22 @@ export function ReviewSessionPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const selectVisibleFindings = () => {
|
||||
const validIds = filteredFindings.map(f => f.id).filter((id): id is string => id !== undefined);
|
||||
setSelectedFindings(new Set(validIds));
|
||||
};
|
||||
|
||||
const selectBySeverity = (severity: FindingWithSelection['severity']) => {
|
||||
const criticalIds = flattenedFindings
|
||||
.filter(f => f.severity === severity && f.id !== undefined)
|
||||
.map(f => f.id!);
|
||||
setSelectedFindings(prev => {
|
||||
const next = new Set(prev);
|
||||
criticalIds.forEach(id => next.add(id));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleExpandFinding = (findingId: string) => {
|
||||
setExpandedFindings(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -116,6 +365,10 @@ export function ReviewSessionPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleFindingClick = (findingId: string) => {
|
||||
setSelectedFindingId(findingId);
|
||||
};
|
||||
|
||||
const exportSelectedAsJson = () => {
|
||||
const selected = flattenedFindings.filter(f => f.id !== undefined && selectedFindings.has(f.id));
|
||||
if (selected.length === 0) return;
|
||||
@@ -148,12 +401,26 @@ export function ReviewSessionPage() {
|
||||
// Severity order for sorting
|
||||
const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
|
||||
|
||||
// Calculate dimension counts
|
||||
const dimensionCounts = React.useMemo(() => {
|
||||
const counts: Record<string, number> = { all: flattenedFindings.length };
|
||||
flattenedFindings.forEach(f => {
|
||||
counts[f.dimension] = (counts[f.dimension] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [flattenedFindings]);
|
||||
|
||||
// Filter and sort findings
|
||||
const filteredFindings = React.useMemo(() => {
|
||||
let filtered = flattenedFindings;
|
||||
|
||||
// Apply dimension filter
|
||||
if (dimensionFilter !== 'all') {
|
||||
filtered = filtered.filter(f => f.dimension === dimensionFilter);
|
||||
}
|
||||
|
||||
// Apply severity filter
|
||||
if (severityFilter.size > 0 && !severityFilter.has('all' as SeverityFilter)) {
|
||||
if (severityFilter.size > 0) {
|
||||
filtered = filtered.filter(f => severityFilter.has(f.severity));
|
||||
}
|
||||
|
||||
@@ -186,7 +453,7 @@ export function ReviewSessionPage() {
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [flattenedFindings, severityFilter, searchQuery, sortField, sortOrder]);
|
||||
}, [flattenedFindings, severityFilter, dimensionFilter, searchQuery, sortField, sortOrder]);
|
||||
|
||||
// Get severity badge props
|
||||
const getSeverityBadge = (severity: FindingWithSelection['severity']) => {
|
||||
@@ -256,6 +523,11 @@ export function ReviewSessionPage() {
|
||||
const dimensions = reviewSession.reviewDimensions || [];
|
||||
const totalFindings = flattenedFindings.length;
|
||||
|
||||
// Determine session status (ACTIVE or ARCHIVED)
|
||||
const isActive = reviewSession._isActive !== false;
|
||||
const sessionStatus = isActive ? 'ACTIVE' : 'ARCHIVED';
|
||||
const phase = reviewSession.phase || 'in-progress';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -266,65 +538,99 @@ export function ReviewSessionPage() {
|
||||
{formatMessage({ id: 'common.actions.back' })}
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">
|
||||
{formatMessage({ id: 'reviewSession.title' })}
|
||||
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
🔍 {reviewSession.session_id}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{reviewSession.session_id}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="review">Review</Badge>
|
||||
<Badge variant={isActive ? "success" : "secondary"} className="text-xs">
|
||||
{sessionStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="info">
|
||||
{formatMessage({ id: 'reviewSession.type' })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-foreground">{totalFindings}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.stats.total' })}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-destructive">{severityCounts.critical}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.severity.critical' })}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-warning">{severityCounts.high}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.severity.high' })}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-foreground">{dimensions.length}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.stats.dimensions' })}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* Review Progress Section */}
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-4">
|
||||
{/* Review Progress Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">📊</span>
|
||||
<span className="font-semibold">{formatMessage({ id: 'reviewSession.progress.title' })}</span>
|
||||
</div>
|
||||
<Badge variant="secondary">{phase.toUpperCase()}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
|
||||
<span className="text-2xl">📊</span>
|
||||
<div>
|
||||
<div className="text-lg font-bold">{totalFindings}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.progress.totalFindings' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-red-100 dark:bg-red-900/20 rounded-lg">
|
||||
<span className="text-2xl">🔴</span>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-red-600 dark:text-red-400">{severityCounts.critical}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.progress.critical' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-orange-100 dark:bg-orange-900/20 rounded-lg">
|
||||
<span className="text-2xl">🟠</span>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-orange-600 dark:text-orange-400">{severityCounts.high}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.progress.high' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-blue-100 dark:bg-blue-900/20 rounded-lg">
|
||||
<span className="text-2xl">🎯</span>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{dimensions.length}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.stats.dimensions' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Fix Progress Carousel */}
|
||||
{sessionId && <FixProgressCarousel sessionId={sessionId} />}
|
||||
|
||||
{/* Filters and Controls */}
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-4">
|
||||
{/* Severity Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['critical', 'high', 'medium', 'low'] as const).map(severity => {
|
||||
const isEnabled = severityFilter.has(severity);
|
||||
const badge = getSeverityBadge(severity);
|
||||
return (
|
||||
<Badge
|
||||
key={severity}
|
||||
variant={isEnabled ? badge.variant : 'outline'}
|
||||
className={`cursor-pointer ${isEnabled ? '' : 'opacity-50'}`}
|
||||
onClick={() => toggleSeverity(severity)}
|
||||
>
|
||||
<badge.icon className="h-3 w-3 mr-1" />
|
||||
{badge.label}: {severityCounts[severity]}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{/* Checkbox-style Severity Filters */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">{formatMessage({ id: 'reviewSession.filters.severity' })}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['critical', 'high', 'medium', 'low'] as const).map(severity => {
|
||||
const isEnabled = severityFilter.has(severity);
|
||||
return (
|
||||
<label
|
||||
key={severity}
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full border cursor-pointer transition-colors ${
|
||||
isEnabled
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background border-border hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isEnabled}
|
||||
onChange={() => toggleSeverity(severity)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{formatMessage({ id: `reviewSession.severity.${severity}` })}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Sort */}
|
||||
@@ -355,6 +661,9 @@ export function ReviewSessionPage() {
|
||||
>
|
||||
{sortOrder === 'asc' ? '↑' : '↓'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={resetFilters}>
|
||||
{formatMessage({ id: 'reviewSession.filters.reset' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Selection Controls */}
|
||||
@@ -368,6 +677,12 @@ export function ReviewSessionPage() {
|
||||
? formatMessage({ id: 'reviewSession.selection.clearAll' })
|
||||
: formatMessage({ id: 'reviewSession.selection.selectAll' })}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={selectVisibleFindings}>
|
||||
{formatMessage({ id: 'reviewSession.selection.selectVisible' })}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => selectBySeverity('critical')}>
|
||||
{formatMessage({ id: 'reviewSession.selection.selectCritical' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -384,12 +699,39 @@ export function ReviewSessionPage() {
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{formatMessage({ id: 'reviewSession.export' })}
|
||||
🔧 {formatMessage({ id: 'reviewSession.export' })}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dimension Tabs */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
dimensionFilter === 'all'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
}`}
|
||||
onClick={() => setDimensionFilter('all')}
|
||||
>
|
||||
{formatMessage({ id: 'reviewSession.dimensionTabs.all' })} ({dimensionCounts.all || 0})
|
||||
</button>
|
||||
{dimensions.map(dim => (
|
||||
<button
|
||||
key={dim.name}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
dimensionFilter === dim.name
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
}`}
|
||||
onClick={() => setDimensionFilter(dim.name)}
|
||||
>
|
||||
{dim.name} ({dim.findings?.length || 0})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Findings List */}
|
||||
{filteredFindings.length === 0 ? (
|
||||
<Card>
|
||||
|
||||
Reference in New Issue
Block a user