diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1c78aeb02..cd2f96058 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ const HowItWorksPage = React.lazy(() => import('./pages/HowItWorksPage').then((m const ProfilePage = React.lazy(() => import('./pages/ProfilePage').then((m) => ({ default: m.ProfilePage }))); const GitHubCallbackPage = React.lazy(() => import('./pages/GitHubCallbackPage').then((m) => ({ default: m.GitHubCallbackPage }))); const BountiesPage = React.lazy(() => import('./pages/BountiesPage').then((m) => ({ default: m.BountiesPage }))); +const ReviewDashboardPage = React.lazy(() => import('./pages/ReviewDashboardPage').then((m) => ({ default: m.ReviewDashboardPage }))); const NotFoundPage = React.lazy(() => import('./pages/NotFoundPage').then((m) => ({ default: m.NotFoundPage }))); function PageLoader() { @@ -46,6 +47,8 @@ export default function App() { /> } /> } /> + } /> + } /> } /> } /> diff --git a/frontend/src/api/reviews.ts b/frontend/src/api/reviews.ts new file mode 100644 index 000000000..d19d46cfc --- /dev/null +++ b/frontend/src/api/reviews.ts @@ -0,0 +1,86 @@ +import { apiClient } from '../services/apiClient'; +import type { + LLMReviewScore, + ReviewConsensus, + Appeal, + AppealTimelineEvent, + ReviewDashboardStats, +} from '../types/review'; + +export interface SubmissionReviewsResponse { + consensus: ReviewConsensus; + reviews: LLMReviewScore[]; +} + +export interface AppealsListResponse { + items: Appeal[]; + total: number; + limit: number; + offset: number; +} + +export interface AppealCreatePayload { + submission_id: string; + reason: string; + evidence: string; + requested_action: string; +} + +export interface AppealResolvePayload { + resolution: string; + status: 'resolved' | 'dismissed'; + reviewer_notes?: string; +} + +export async function getSubmissionReviews(submissionId: string): Promise { + return apiClient(`/api/reviews/submissions/${submissionId}`); +} + +export async function getBountyReviews(bountyId: string): Promise { + return apiClient(`/api/reviews/bounties/${bountyId}`); +} + +export async function getReviewDashboardStats(): Promise { + return apiClient('/api/reviews/stats'); +} + +export async function listAppeals(params?: { + status?: string; + limit?: number; + offset?: number; +}): Promise { + return apiClient('/api/reviews/appeals', { + params: params as Record, + }); +} + +export async function getAppeal(appealId: string): Promise { + return apiClient(`/api/reviews/appeals/${appealId}`); +} + +export async function createAppeal(payload: AppealCreatePayload): Promise { + return apiClient('/api/reviews/appeals', { + method: 'POST', + body: payload, + }); +} + +export async function resolveAppeal( + appealId: string, + payload: AppealResolvePayload +): Promise { + return apiClient(`/api/reviews/appeals/${appealId}/resolve`, { + method: 'POST', + body: payload, + }); +} + +export async function getAppealTimeline(appealId: string): Promise { + return apiClient(`/api/reviews/appeals/${appealId}/timeline`); +} + +export async function assignHumanReviewer(appealId: string): Promise { + return apiClient(`/api/reviews/appeals/${appealId}/assign`, { + method: 'POST', + }); +} \ No newline at end of file diff --git a/frontend/src/components/review/AppealWorkflow.tsx b/frontend/src/components/review/AppealWorkflow.tsx new file mode 100644 index 000000000..77981bd24 --- /dev/null +++ b/frontend/src/components/review/AppealWorkflow.tsx @@ -0,0 +1,208 @@ +import React, { useState } from 'react'; +import { motion } from 'framer-motion'; +import { Scale, UserCheck, MessageSquare, ThumbsUp, ThumbsDown, Clock, AlertTriangle } from 'lucide-react'; +import type { Appeal, AppealStatus } from '../../types/review'; +import { useAppealTimeline } from '../../hooks/useReviews'; + +const fadeIn = { + initial: { opacity: 0, y: 12 }, + animate: { opacity: 1, y: 0, transition: { duration: 0.3 } }, +}; + +const STATUS_META: Record = { + open: { label: 'Open', color: 'text-status-warning', bg: 'bg-status-warning/5', border: 'border-status-warning/20' }, + in_review: { label: 'In Review', color: 'text-status-info', bg: 'bg-status-info/5', border: 'border-status-info/20' }, + resolved: { label: 'Resolved', color: 'text-emerald', bg: 'bg-emerald-bg', border: 'border-emerald-border' }, + dismissed: { label: 'Dismissed', color: 'text-status-error', bg: 'bg-status-error/5', border: 'border-status-error/20' }, +}; + +interface AppealWorkflowProps { + appeal: Appeal; + onAssign?: (appealId: string) => void; + onResolve?: (appealId: string, resolution: string, status: 'resolved' | 'dismissed') => void; + isReviewer?: boolean; +} + +export function AppealWorkflow({ appeal, onAssign, onResolve, isReviewer = false }: AppealWorkflowProps) { + const [showTimeline, setShowTimeline] = useState(false); + const [resolutionText, setResolutionText] = useState(''); + const [showResolve, setShowResolve] = useState(false); + const { data: timeline } = useAppealTimeline(showTimeline ? appeal.id : undefined); + + const meta = STATUS_META[appeal.status]; + + return ( + +
+
+
+ +
+
+

Appeal #{appeal.id.slice(0, 8)}

+

+ by {appeal.appellant_username} · {new Date(appeal.created_at).toLocaleDateString()} +

+
+
+ + {meta.label} + +
+ + {/* Reason */} +
+

Reason for Appeal

+

{appeal.reason}

+
+ + {appeal.evidence && ( +
+

Evidence

+

{appeal.evidence}

+
+ )} + + {/* Assigned reviewer */} + {appeal.assigned_human_reviewer && ( +
+ + + Assigned to: {appeal.assigned_human_reviewer} + +
+ )} + + {/* Actions */} +
+ {/* Assign reviewer */} + {isReviewer && appeal.status === 'open' && onAssign && ( + + )} + + {/* Resolve / Dismiss */} + {isReviewer && (appeal.status === 'in_review' || appeal.status === 'open') && ( + + )} + + {/* Timeline toggle */} + +
+ + {/* Resolve form */} + {showResolve && ( +
+