feat(ticket): add paginated class detection preview with image and map

This commit is contained in:
2026-07-08 10:48:03 +05:30
parent fb9a654aea
commit 23186ffd29
11 changed files with 447 additions and 143 deletions

View File

@@ -0,0 +1,280 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, ChevronRight } from 'lucide-react';
import DetectionLocationMap from '@/components/map/detectionLocationMap';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import { cn } from '@/lib/utils';
import { useTicketClassDetectionsQuery } from '../../hooks/useTicketQueries';
interface TicketClassDetectionPreviewProps {
videoId?: string | null;
defectClassName: string;
displayName: string;
totalCount?: number;
}
function DetectionMetadataBar({
items,
}: {
items: { label: string; value: string | number }[];
}) {
return (
<div className="rounded-lg bg-muted/40 px-4 py-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:divide-x sm:divide-border/70">
{items.map((item, index) => (
<div
key={item.label}
className={cn(
'space-y-1',
index === 0 && 'sm:pr-4',
index === 1 && 'sm:px-4',
index === 2 && 'sm:pl-4',
)}
>
<p className="text-xs text-muted-foreground">{item.label}</p>
<div className="text-sm font-semibold text-foreground">
{item.value}
</div>
</div>
))}
</div>
</div>
);
}
function TicketClassDetectionPreviewSkeleton() {
return (
<>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="min-w-0 space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="aspect-video w-full rounded-lg" />
</div>
<div className="min-w-0 space-y-2">
<Skeleton className="h-3 w-28" />
<Skeleton
className="w-full rounded-lg"
style={{ aspectRatio: '16 / 9' }}
/>
</div>
</div>
<div className="rounded-lg bg-muted/40 px-4 py-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:divide-x sm:divide-border/70">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className={cn(
'space-y-1',
index === 0 && 'sm:pr-4',
index === 1 && 'sm:px-4',
index === 2 && 'sm:pl-4',
)}
>
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
</div>
</>
);
}
export function TicketClassDetectionPreview({
videoId,
defectClassName,
displayName,
totalCount,
}: TicketClassDetectionPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const visual = getDefectVisual(defectClassName);
const IssueIcon = visual.icon;
useEffect(() => {
setCurrentIndex(0);
}, [defectClassName]);
const queryParams = useMemo(
() => ({
class_name: defectClassName,
skip: currentIndex,
limit: 1,
sort: 'timestamp_asc',
}),
[currentIndex, defectClassName],
);
const detectionsQuery = useTicketClassDetectionsQuery(
videoId ?? undefined,
queryParams,
);
const detectionResult = detectionsQuery.data;
const activeDetection = detectionResult?.items[0];
const detectionCount = detectionResult?.total ?? totalCount ?? 0;
const mediaAspectRatio =
detectionResult &&
detectionResult.video_width > 0 &&
detectionResult.video_height > 0
? `${detectionResult.video_width} / ${detectionResult.video_height}`
: '16 / 9';
useEffect(() => {
if (detectionCount === 0 && currentIndex !== 0) {
setCurrentIndex(0);
return;
}
if (detectionCount > 0 && currentIndex > detectionCount - 1) {
setCurrentIndex(detectionCount - 1);
}
}, [currentIndex, detectionCount]);
const isPreviousDisabled = currentIndex === 0 || detectionsQuery.isLoading;
const isNextDisabled =
detectionCount === 0 ||
currentIndex + 1 >= detectionCount ||
detectionsQuery.isLoading;
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<span
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full',
visual.cardClassName,
)}
>
<IssueIcon className={cn('size-5', visual.colorClassName)} />
</span>
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="truncate text-lg font-semibold text-foreground">
{displayName}
</h3>
<Badge
variant="secondary"
className={cn(
'rounded-full border-0 px-2.5 py-1 text-xs font-semibold',
visual.cardClassName,
visual.colorClassName,
)}
>
{detectionCount} Detections
</Badge>
</div>
<p className="text-sm text-muted-foreground">
Review detections in ascending timestamp order.
</p>
</div>
</div>
<div className="flex items-center gap-2 self-start sm:self-auto">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCurrentIndex((index) => Math.max(index - 1, 0))}
disabled={isPreviousDisabled}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<div className="min-w-16 text-center text-sm font-medium text-foreground">
{detectionCount === 0
? '0 of 0'
: `${currentIndex + 1} of ${detectionCount}`}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCurrentIndex((index) => index + 1)}
disabled={isNextDisabled}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
{detectionsQuery.isLoading && !detectionsQuery.data ? (
<TicketClassDetectionPreviewSkeleton />
) : detectionsQuery.isError ? (
<div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-8 text-center">
<div className="flex flex-col items-center gap-2">
<AlertTriangle className="size-6 text-destructive" />
<p className="text-sm font-medium text-destructive">
Failed to load detection preview.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void detectionsQuery.refetch()}
>
Retry
</Button>
</div>
</div>
) : !activeDetection || !detectionResult ? (
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
No detections are available for this issue.
</div>
) : (
<>
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2">
<div className="min-w-0 space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Detection Image
</p>
<div className="overflow-hidden rounded-lg">
<AnnotatedDetectionImage
detection={activeDetection}
videoWidth={detectionResult.video_width}
videoHeight={detectionResult.video_height}
/>
</div>
</div>
<DetectionLocationMap
className="min-w-0"
latitude={activeDetection.location.latitude}
longitude={activeDetection.location.longitude}
label={`${displayName} - Frame ${activeDetection.frame.number}`}
title="Location on Map"
variant="plain"
aspectRatio={mediaAspectRatio}
showCoordinates={false}
/>
</div>
<DetectionMetadataBar
items={[
{
label: 'Confidence',
value: `${Math.round(activeDetection.detection.confidence * 100)}%`,
},
{
label: 'Frame',
value: activeDetection.frame.number,
},
{
label: 'Timestamp',
value: `${activeDetection.frame.timestamp_seconds}s`,
},
]}
/>
</>
)}
</div>
);
}

View File

@@ -2,18 +2,17 @@
import { useEffect, useMemo, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { CalendarCheck2, Component, FileVideo, ListChecks } from 'lucide-react';
import { Component, ListChecks } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DEFECT_CLASS_STATUS_CONFIG } from '@/constants/defectClassStatus';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import { cn } from '@/lib/utils';
import type { TicketOverviewDetail } from '@/types';
import { formatDate } from '@/utils/date';
import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
import { TicketClassDetectionPreview } from './TicketClassDetectionPreview';
interface TicketDefectClassTabsProps {
ticketId: string;
@@ -64,10 +63,15 @@ export function TicketDefectClassTabs({
const selectedIssue = ticket?.ai_result.detections_by_class.find(
(item) => item.class_name === selectedClass,
);
const selectedIssueVisual = getDefectVisual(selectedIssue?.class_name ?? '');
const SelectedIssueIcon = selectedIssueVisual.icon;
const completedAt = ticket?.ai_result.completed_at;
const videoName = ticket?.video?.name;
const selectedClassItem = defectClasses.find(
(item) => item.class_name === selectedClass,
);
const previewDisplayName =
selectedIssue?.display_name ??
selectedClassItem?.display_name ??
selectedClass ??
'Detection';
const previewVideoId = ticket?.video_id ?? defectClassesQuery.data?.video_id;
if (defectClassesQuery.isLoading) {
return (
@@ -75,12 +79,12 @@ export function TicketDefectClassTabs({
<CardContent className="space-y-4 p-5">
<div className="h-5 w-24 animate-pulse rounded bg-muted" />
<div className="flex flex-col gap-2">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="h-11 w-full animate-pulse rounded-lg bg-muted"
/>
))}
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="h-11 w-full animate-pulse rounded-lg bg-muted"
/>
))}
</div>
</CardContent>
</Card>
@@ -145,89 +149,12 @@ export function TicketDefectClassTabs({
</div>
<div className="min-w-0 p-5">
{selectedIssue ? (
<div className="space-y-4">
<div className="flex min-w-0 items-center gap-3">
<span
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full',
selectedIssueVisual.cardClassName,
)}
>
<SelectedIssueIcon
className={cn(
'size-5',
selectedIssueVisual.colorClassName,
)}
/>
</span>
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
<h3 className="truncate text-lg font-semibold text-foreground">
{selectedIssue.display_name}
</h3>
<Badge
variant="secondary"
className={cn(
'shrink-0 rounded-full border-0 px-2.5 py-1 text-xs font-semibold',
selectedIssueVisual.cardClassName,
selectedIssueVisual.colorClassName,
)}
>
{selectedIssue.count} Detections
</Badge>
</div>
</div>
<div className="border-t pt-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">
Current Issue
</p>
<p className="text-sm font-semibold text-foreground">
{selectedIssue.display_name}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">
Current Issue Count
</p>
<p className="text-sm font-semibold text-violet-600">
{selectedIssue.count}
</p>
</div>
{completedAt ? (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<CalendarCheck2 className="size-4" />
<span>AI Completed At</span>
</div>
<p className="text-sm font-semibold text-foreground">
{formatDate(completedAt)}
</p>
</div>
) : null}
{videoName ? (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<FileVideo className="size-4" />
<span>Video</span>
</div>
<p className="truncate text-sm font-semibold text-foreground">
{videoName}
</p>
</div>
) : null}
</div>
</div>
</div>
) : (
<div className="flex min-h-48 items-center justify-center text-center">
<p className="text-sm font-medium text-muted-foreground">
Select an issue to view details.
</p>
</div>
)}
<TicketClassDetectionPreview
videoId={previewVideoId}
defectClassName={selectedClass}
displayName={previewDisplayName}
totalCount={selectedIssue?.count}
/>
</div>
</CardContent>
</Card>