193 lines
6.1 KiB
TypeScript
193 lines
6.1 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef } from 'react';
|
|
import {
|
|
CheckCircle2,
|
|
Clock3,
|
|
Hourglass,
|
|
ListChecks,
|
|
Wrench,
|
|
XCircle,
|
|
} from 'lucide-react';
|
|
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from '@/components/ui/tooltip';
|
|
import { cn } from '@/lib/utils';
|
|
import type { DetectionResultItem } from '@/types';
|
|
|
|
import {
|
|
formatVideoTimestamp,
|
|
getIssueId,
|
|
getIssueReviewStatus,
|
|
type IssueReviewStatus,
|
|
} from './reviewTypes';
|
|
|
|
const STATUS_CONFIG: Record<
|
|
IssueReviewStatus,
|
|
{
|
|
label: string;
|
|
icon: typeof Hourglass;
|
|
className: string;
|
|
}
|
|
> = {
|
|
not_submitted: {
|
|
label: 'Awaiting Repair',
|
|
icon: Wrench,
|
|
className: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-300',
|
|
},
|
|
pending: {
|
|
label: 'Under Review',
|
|
icon: Hourglass,
|
|
className: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
|
},
|
|
approved: {
|
|
label: 'Approved',
|
|
icon: CheckCircle2,
|
|
className: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
|
},
|
|
rejected: {
|
|
label: 'Rejected',
|
|
icon: XCircle,
|
|
className: 'bg-destructive/10 text-destructive',
|
|
},
|
|
};
|
|
|
|
interface IssueReviewQueueProps {
|
|
detections: DetectionResultItem[];
|
|
totalCount: number;
|
|
selectedIssueId?: string;
|
|
hasNextPage: boolean;
|
|
isFetchingNextPage: boolean;
|
|
onSelect: (issueId: string) => void;
|
|
onLoadMore: () => void;
|
|
}
|
|
|
|
export function IssueReviewQueue({
|
|
detections,
|
|
totalCount,
|
|
selectedIssueId,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
onSelect,
|
|
onLoadMore,
|
|
}: IssueReviewQueueProps) {
|
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
const target = loadMoreRef.current;
|
|
if (!target || !hasNextPage || isFetchingNextPage) return;
|
|
|
|
const root = target.closest<HTMLElement>(
|
|
'[data-slot="scroll-area-viewport"]',
|
|
);
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0]?.isIntersecting) onLoadMore();
|
|
},
|
|
{ root, rootMargin: '160px 0px' },
|
|
);
|
|
|
|
observer.observe(target);
|
|
return () => observer.disconnect();
|
|
}, [hasNextPage, isFetchingNextPage, onLoadMore]);
|
|
|
|
return (
|
|
<Card className="overflow-hidden xl:h-full xl:min-h-0">
|
|
<CardHeader className="border-b">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<ListChecks className="size-5 text-primary" />
|
|
Repair Issues
|
|
<Badge variant="secondary" className="ml-auto rounded-full">
|
|
{totalCount}
|
|
</Badge>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="min-h-0 flex-1 p-2">
|
|
<ScrollArea className="h-112 pr-2 xl:h-full">
|
|
<div className="space-y-2 p-1">
|
|
{detections.map((detection, index) => {
|
|
const issueId = getIssueId(detection);
|
|
const defectLabel = detection.detection.display_name;
|
|
const status = getIssueReviewStatus(detection);
|
|
const config = STATUS_CONFIG[status];
|
|
const StatusIcon = config.icon;
|
|
const isSelected = issueId === selectedIssueId;
|
|
|
|
return (
|
|
<button
|
|
key={detection.id}
|
|
type="button"
|
|
onClick={() => onSelect(issueId)}
|
|
aria-current={isSelected ? 'true' : undefined}
|
|
className={cn(
|
|
'w-full rounded-lg border p-3 text-left transition-colors',
|
|
'hover:border-primary/40 hover:bg-muted/50',
|
|
isSelected
|
|
? 'border-primary bg-primary/6 shadow-sm'
|
|
: 'border-border bg-card',
|
|
)}
|
|
>
|
|
<div className="min-w-0 space-y-2">
|
|
<div className="flex min-w-0 items-center justify-between gap-2">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<p className="min-w-0 flex-1 truncate text-sm font-semibold">
|
|
{defectLabel} #{index + 1}
|
|
</p>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
{defectLabel} #{index + 1}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<span className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
|
|
<Clock3 className="size-3.5" />
|
|
{formatVideoTimestamp(
|
|
detection.frame.timestamp_seconds,
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
<Badge
|
|
variant="secondary"
|
|
className={cn('shrink-0 px-1.5', config.className)}
|
|
>
|
|
<StatusIcon />
|
|
{config.label}
|
|
</Badge>
|
|
<span aria-hidden="true">|</span>
|
|
<span>Frame {detection.frame.number}</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
<div ref={loadMoreRef} className="space-y-2 py-1">
|
|
{isFetchingNextPage ? (
|
|
<>
|
|
<Skeleton className="h-20 w-full" />
|
|
<Skeleton className="h-20 w-full" />
|
|
</>
|
|
) : hasNextPage ? (
|
|
<p className="py-3 text-center text-xs text-muted-foreground">
|
|
Scroll to load more issues
|
|
</p>
|
|
) : detections.length > 0 ? (
|
|
<p className="py-3 text-center text-xs text-muted-foreground">
|
|
All {totalCount} issues loaded
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</ScrollArea>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|