import { useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; import { AlertCircle, ArrowLeft, Check, Eye, Filter, ListChecks, Search, X, } from "lucide-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Badge, BadgeProps } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { useClaims, useUpdateClaimStatus } from "@/hooks/useClaims"; import { useProject } from "@/hooks/useProjects"; import { formatDateTime, formatPercent, humanizeValue, reviewLabel, } from "@/lib/display"; import { Claim } from "@/lib/api/claims"; function statusVariant(status: string | undefined): BadgeProps["variant"] { switch (reviewLabel(status)) { case "approved": return "success"; case "rejected": return "destructive"; case "candidate": return "warning"; default: return "outline"; } } function confidenceBucket(claim: Claim): string { if (typeof claim.confidence !== "number") return "unknown"; if (claim.confidence >= 0.9) return "auto-approve candidate"; if (claim.confidence >= 0.7) return "normal review"; return "priority review"; } function claimObject(claim: Claim): string { return humanizeValue(claim.object ?? claim.object_value); } export default function ReviewPage() { const navigate = useNavigate(); const { projectId } = useParams<{ projectId: string }>(); const projectName = projectId ?? ""; const { data: project } = useProject(projectName); const claims = useClaims(projectName, { includeCandidates: true, limit: 300, }); const updateStatus = useUpdateClaimStatus(projectName); const [statusFilter, setStatusFilter] = useState("all"); const [search, setSearch] = useState(""); const [selectedId, setSelectedId] = useState(null); const filteredClaims = useMemo(() => { const query = search.trim().toLowerCase(); return (claims.data ?? []).filter((claim) => { if (statusFilter !== "all" && reviewLabel(claim.status) !== statusFilter) { return false; } if (!query) return true; return [ claim.subject, claim.predicate, claimObject(claim), claim.evidence_text, claim.page_url, claim.source, ] .map((value) => humanizeValue(value, "").toLowerCase()) .some((value) => value.includes(query)); }); }, [claims.data, search, statusFilter]); const selectedClaim = useMemo(() => { return ( filteredClaims.find((claim) => claim.id === selectedId) ?? filteredClaims[0] ); }, [filteredClaims, selectedId]); const counts = useMemo(() => { const all = claims.data ?? []; return { total: all.length, candidate: all.filter((claim) => reviewLabel(claim.status) === "candidate") .length, approved: all.filter((claim) => reviewLabel(claim.status) === "approved") .length, rejected: all.filter((claim) => reviewLabel(claim.status) === "rejected") .length, }; }, [claims.data]); const applyStatus = async (claim: Claim, status: string) => { try { await updateStatus.mutateAsync({ claimId: claim.id, status, reason: status === "rejected" ? "Rejected from Claim Review" : "Updated from Claim Review", }); toast.success(`Claim ${status}`); } catch (error) { toast.error((error as Error).message); } }; return (

Claim Review

{project?.name ?? projectName} · 후보, 승인, 반려 상태 분리 검토

{claims.isError && ( {(claims.error as Error).message} )}
Review Queue 신뢰도, 출처, 생성 방식, 검증 결과를 함께 확인합니다.
setSearch(event.target.value)} className="w-56 pl-9" placeholder="Search claims" />
{claims.isLoading && (
{Array.from({ length: 5 }).map((_, index) => ( ))}
)} {!claims.isLoading && filteredClaims.length === 0 && (

조건에 맞는 클레임이 없습니다.

)}
{filteredClaims.map((claim) => (
))}
); } function Metric({ label, value }: { label: string; value: number }) { return (
{label}
{value.toLocaleString()}
); } function DetailRow({ label, value }: { label: string; value: unknown }) { return (
{label}
{humanizeValue(value)}
); }