From 23186ffd2971d5f638f597ea1c8c8c890862c2b3 Mon Sep 17 00:00:00 2001 From: "santasri.pachhal" Date: Wed, 8 Jul 2026 10:48:03 +0530 Subject: [PATCH] feat(ticket): add paginated class detection preview with image and map --- .../TicketClassDetectionPreview.tsx | 280 ++++++++++++++++++ .../components/TicketDefectClassTabs.tsx | 119 ++------ .../ticket/hooks/useTicketQueries.ts | 34 ++- .../(modules)/ticket/queries/ticketKeys.ts | 4 +- .../upload/components/UploadListColumns.tsx | 14 +- .../annotation/boundingBoxOverlay.tsx | 4 +- src/components/map/detectionLocationMap.tsx | 83 ++++-- .../video/annotatedDetectionImage.tsx | 4 +- src/constants/apiRoutes.ts | 1 + src/services/api/video.service.ts | 22 ++ src/types/detection.ts | 25 +- 11 files changed, 447 insertions(+), 143 deletions(-) create mode 100644 src/app/(modules)/ticket/[ticketId]/components/TicketClassDetectionPreview.tsx diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketClassDetectionPreview.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketClassDetectionPreview.tsx new file mode 100644 index 0000000..7a88f48 --- /dev/null +++ b/src/app/(modules)/ticket/[ticketId]/components/TicketClassDetectionPreview.tsx @@ -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 ( +
+
+ {items.map((item, index) => ( +
+

{item.label}

+
+ {item.value} +
+
+ ))} +
+
+ ); +} + +function TicketClassDetectionPreviewSkeleton() { + return ( + <> +
+
+ + +
+
+ + +
+
+ +
+
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ + +
+ ))} +
+
+ + ); +} + +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 ( +
+
+
+ + + +
+
+

+ {displayName} +

+ + {detectionCount} Detections + +
+

+ Review detections in ascending timestamp order. +

+
+
+ +
+ +
+ {detectionCount === 0 + ? '0 of 0' + : `${currentIndex + 1} of ${detectionCount}`} +
+ +
+
+ + {detectionsQuery.isLoading && !detectionsQuery.data ? ( + + ) : detectionsQuery.isError ? ( +
+
+ +

+ Failed to load detection preview. +

+ +
+
+ ) : !activeDetection || !detectionResult ? ( +
+ No detections are available for this issue. +
+ ) : ( + <> +
+
+

+ Detection Image +

+
+ +
+
+ + +
+ + + + )} +
+ ); +} diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketDefectClassTabs.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketDefectClassTabs.tsx index 52b98da..577fe60 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/TicketDefectClassTabs.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/TicketDefectClassTabs.tsx @@ -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({
- {Array.from({ length: 3 }).map((_, index) => ( -
- ))} + {Array.from({ length: 3 }).map((_, index) => ( +
+ ))}
@@ -145,89 +149,12 @@ export function TicketDefectClassTabs({
- {selectedIssue ? ( -
-
- - - -
-

- {selectedIssue.display_name} -

- - {selectedIssue.count} Detections - -
-
- -
-
-
-

- Current Issue -

-

- {selectedIssue.display_name} -

-
-
-

- Current Issue Count -

-

- {selectedIssue.count} -

-
- {completedAt ? ( -
-
- - AI Completed At -
-

- {formatDate(completedAt)} -

-
- ) : null} - {videoName ? ( -
-
- - Video -
-

- {videoName} -

-
- ) : null} -
-
-
- ) : ( -
-

- Select an issue to view details. -

-
- )} +
diff --git a/src/app/(modules)/ticket/hooks/useTicketQueries.ts b/src/app/(modules)/ticket/hooks/useTicketQueries.ts index 080d62a..19e73d2 100644 --- a/src/app/(modules)/ticket/hooks/useTicketQueries.ts +++ b/src/app/(modules)/ticket/hooks/useTicketQueries.ts @@ -4,7 +4,7 @@ import { useMemo } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { ticketService } from '@/services/api'; +import { ticketService, videoService } from '@/services/api'; import type { AssignTicketPayload, CloseTicketPayload, @@ -15,6 +15,7 @@ import type { SubmitRepairUploadPayload, TicketDetail, TicketListParams, + VideoDetectionsParams, } from '@/types'; import { ticketKeys } from '../queries/ticketKeys'; @@ -85,6 +86,37 @@ export function useTicketDefectClassesQuery(ticketId: string | undefined) { }); } +export function useTicketClassDetectionsQuery( + videoId: string | undefined, + params: VideoDetectionsParams, +) { + const queryParams = useMemo( + () => ({ + skip: params.skip ?? 0, + limit: params.limit ?? 1, + class_name: params.class_name, + min_confidence: params.min_confidence, + sort: params.sort ?? 'timestamp_asc', + search: params.search, + }), + [ + params.class_name, + params.limit, + params.min_confidence, + params.search, + params.skip, + params.sort, + ], + ); + + return useQuery({ + queryKey: ticketKeys.classDetections(videoId ?? '', queryParams), + queryFn: () => + videoService.getVideoDetections(videoId as string, queryParams), + enabled: Boolean(videoId && queryParams.class_name), + }); +} + export function useTicketExtensionRequestQuery( ticketId: string | undefined, assignmentId: number | undefined, diff --git a/src/app/(modules)/ticket/queries/ticketKeys.ts b/src/app/(modules)/ticket/queries/ticketKeys.ts index 25c0883..66bf22e 100644 --- a/src/app/(modules)/ticket/queries/ticketKeys.ts +++ b/src/app/(modules)/ticket/queries/ticketKeys.ts @@ -1,4 +1,4 @@ -import type { TicketListParams } from '@/types'; +import type { TicketListParams, VideoDetectionsParams } from '@/types'; export const ticketKeys = { all: ['tickets'] as const, @@ -9,6 +9,8 @@ export const ticketKeys = { [...ticketKeys.details(), ticketId, 'overview'] as const, classDetail: (ticketId: string, defectClass: string) => [...ticketKeys.details(), ticketId, 'class', defectClass] as const, + classDetections: (videoId: string, params: VideoDetectionsParams) => + [...ticketKeys.details(), 'video', videoId, 'detections', params] as const, defectClasses: (ticketId: string) => [...ticketKeys.details(), ticketId, 'defect-classes'] as const, extensionRequest: (ticketId: string, assignmentId: number | undefined) => diff --git a/src/app/(modules)/upload/components/UploadListColumns.tsx b/src/app/(modules)/upload/components/UploadListColumns.tsx index bbcef5c..726f778 100644 --- a/src/app/(modules)/upload/components/UploadListColumns.tsx +++ b/src/app/(modules)/upload/components/UploadListColumns.tsx @@ -46,11 +46,7 @@ export function useUploadListColumns( : undefined } role={thumbnail ? 'img' : undefined} - aria-label={ - thumbnail - ? `Thumbnail for ${row.original.video_name}` - : undefined - } + aria-label={thumbnail ? 'Upload thumbnail' : undefined} > {!thumbnail ? (