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 ? (
@@ -59,14 +55,6 @@ export function useUploadListColumns(
);
},
},
- {
- accessorKey: 'video_name',
- header: 'Video Name',
- size: 150,
- cell: ({ row }) => (
- {row.original.video_name}
- ),
- },
{
id: 'uploaded_by',
header: 'Uploader',
diff --git a/src/components/annotation/boundingBoxOverlay.tsx b/src/components/annotation/boundingBoxOverlay.tsx
index dc7ff2e..9e4ae5e 100644
--- a/src/components/annotation/boundingBoxOverlay.tsx
+++ b/src/components/annotation/boundingBoxOverlay.tsx
@@ -1,8 +1,8 @@
import { getDefectVisual } from '@/constants/defectVisualConfig';
-import type { DetectionResultLog } from '@/types';
+import type { DetectionResultItem } from '@/types';
interface BoundingBoxOverlayProps {
- detections: DetectionResultLog[];
+ detections: DetectionResultItem[];
videoWidth: number;
videoHeight: number;
}
diff --git a/src/components/map/detectionLocationMap.tsx b/src/components/map/detectionLocationMap.tsx
index c7a2760..44005b5 100644
--- a/src/components/map/detectionLocationMap.tsx
+++ b/src/components/map/detectionLocationMap.tsx
@@ -2,6 +2,7 @@
import dynamic from 'next/dynamic';
import { MapPin } from 'lucide-react';
+import { cn } from '@/lib/utils';
import {
Card,
CardContent,
@@ -14,6 +15,12 @@ type DetectionLocationMapProps = {
latitude: number | null;
longitude: number | null;
label: string;
+ title?: string;
+ description?: string;
+ aspectRatio?: string;
+ showCoordinates?: boolean;
+ variant?: 'card' | 'plain';
+ className?: string;
};
type ClientDetectionLocationMapProps = {
@@ -70,44 +77,66 @@ export default function DetectionLocationMap({
latitude,
longitude,
label,
+ title = 'Detection Location',
+ description = 'Selected log GPS coordinates',
+ aspectRatio = '16 / 9',
+ showCoordinates = true,
+ variant = 'card',
+ className,
}: DetectionLocationMapProps) {
+ const mapContent =
+ latitude == null || longitude == null ? (
+
+ Coordinates are unavailable for the selected log.
+
+ ) : (
+ <>
+ {showCoordinates ? (
+
+ {latitude.toFixed(6)}, {longitude.toFixed(6)}
+
+ ) : null}
+
+
+
+ >
+ );
+
+ if (variant === 'plain') {
+ return (
+
+ {title ? (
+
{title}
+ ) : null}
+ {mapContent}
+
+ );
+ }
+
return (
-
+
-
- Detection Location
-
-
- Selected log GPS coordinates
-
+ {title}
+ {description}
-
- {latitude == null || longitude == null ? (
-
- Coordinates are unavailable for the selected log.
-
- ) : (
- <>
-
- {latitude.toFixed(6)}, {longitude.toFixed(6)}
-
-
-
-
- >
- )}
-
+ {mapContent}
);
}
diff --git a/src/components/video/annotatedDetectionImage.tsx b/src/components/video/annotatedDetectionImage.tsx
index f91ed38..bfa0e71 100644
--- a/src/components/video/annotatedDetectionImage.tsx
+++ b/src/components/video/annotatedDetectionImage.tsx
@@ -4,12 +4,12 @@ import { AlertTriangle, ImageIcon, Loader2 } from 'lucide-react';
import Image from 'next/image';
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
-import type { DetectionResultLog } from '@/types';
+import type { DetectionResultItem } from '@/types';
import BoundingBoxOverlay from '@/components/annotation/boundingBoxOverlay';
interface AnnotatedDetectionImageProps {
- detection?: DetectionResultLog;
+ detection?: DetectionResultItem;
videoWidth: number;
videoHeight: number;
}
diff --git a/src/constants/apiRoutes.ts b/src/constants/apiRoutes.ts
index efcb6cf..2c8b4b4 100644
--- a/src/constants/apiRoutes.ts
+++ b/src/constants/apiRoutes.ts
@@ -54,6 +54,7 @@ export const API_ROUTES = {
UPLOAD_EVENTS: (token: string) =>
`/biz/api/v1/uploads/events?sse_token=${encodeURIComponent(token)}`,
RESULTS: (id: string) => `/biz/api/v1/results/${id}/completed`,
+ DETECTIONS: (id: string) => `/biz/api/v1/results/${id}/detections`,
},
TICKETS: {
BASE: '/biz/api/v1/tickets',
diff --git a/src/services/api/video.service.ts b/src/services/api/video.service.ts
index b5a6822..117a7db 100644
--- a/src/services/api/video.service.ts
+++ b/src/services/api/video.service.ts
@@ -5,6 +5,8 @@ import {
PaginationParams,
SseTokenResponse,
UploadListResponse,
+ VideoDetectionsParams,
+ VideoDetectionsResponse,
} from '@/types';
/**
@@ -59,6 +61,26 @@ export const videoService = {
return response.data;
},
+ getVideoDetections: async (
+ videoId: string,
+ params?: VideoDetectionsParams,
+ ): Promise => {
+ const response = await axiosClient.get(
+ API_ROUTES.VIDEOS.DETECTIONS(videoId),
+ {
+ params: {
+ 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,
+ },
+ },
+ );
+ return response.data;
+ },
+
/**
* Fetch the annotated video as a blob through the authenticated axios client.
*
diff --git a/src/types/detection.ts b/src/types/detection.ts
index cc51430..882ed93 100644
--- a/src/types/detection.ts
+++ b/src/types/detection.ts
@@ -14,7 +14,7 @@ export type DetectionBoundingBox = {
height: number;
};
-export type DetectionResultLog = {
+export type DetectionResultItem = {
id: string;
detection: {
id: number;
@@ -34,6 +34,9 @@ export type DetectionResultLog = {
latitude: number | null;
longitude: number | null;
};
+};
+
+export type DetectionResultLog = DetectionResultItem & {
running_counts: {
total: number;
by_class: Array<{
@@ -43,3 +46,23 @@ export type DetectionResultLog = {
}>;
};
};
+
+export type VideoDetectionsParams = {
+ skip?: number;
+ limit?: number;
+ class_name?: string | string[];
+ min_confidence?: number;
+ sort?: string;
+ search?: string;
+};
+
+export type VideoDetectionsResponse = {
+ video_id: string;
+ video_width: number;
+ video_height: number;
+ skip: number;
+ limit: number;
+ total: number;
+ sort: string;
+ items: DetectionResultItem[];
+};