Files
road-monitoring-ui/src/app/(modules)/ticket/[ticketId]/review/components/IssueReviewQueue.tsx

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>
);
}