- embed detection previews within work assignments - allow discarding only unassigned detections - improve ticket detail labels, counts, and layout
324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
|
|
|
|
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
|
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 {
|
|
useDiscardDetectionMutation,
|
|
useTicketDetectionsQuery,
|
|
} from '../../hooks/useTicketQueries';
|
|
import { DetectionDiscardDialog } from './DetectionDiscardDialog';
|
|
|
|
interface TicketDetectionPreviewProps {
|
|
ticketId: string;
|
|
videoId?: string | null;
|
|
assignmentId?: number;
|
|
assignmentStatus?: 'unassigned';
|
|
onDetectionCountChange?: (count: number | undefined) => void;
|
|
}
|
|
|
|
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 TicketDetectionPreviewSkeleton() {
|
|
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 TicketDetectionPreview({
|
|
ticketId,
|
|
videoId,
|
|
assignmentId,
|
|
assignmentStatus,
|
|
onDetectionCountChange,
|
|
}: TicketDetectionPreviewProps) {
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
|
|
const isUnassignedPreview = assignmentStatus === 'unassigned';
|
|
|
|
useEffect(() => {
|
|
setCurrentIndex(0);
|
|
}, [assignmentId, assignmentStatus, videoId]);
|
|
|
|
const queryParams = useMemo(
|
|
() => ({
|
|
skip: currentIndex,
|
|
limit: 1,
|
|
assignment_id: isUnassignedPreview ? undefined : assignmentId,
|
|
assignment_status: isUnassignedPreview
|
|
? ('unassigned' as const)
|
|
: undefined,
|
|
sort: 'timestamp_asc',
|
|
}),
|
|
[assignmentId, currentIndex, isUnassignedPreview],
|
|
);
|
|
|
|
const detectionsQuery = useTicketDetectionsQuery(
|
|
videoId ?? undefined,
|
|
queryParams,
|
|
);
|
|
const detectionResult = detectionsQuery.data;
|
|
const activeDetection = detectionResult?.items[0];
|
|
const confirmedDetectionCount = detectionResult?.total;
|
|
const detectionCount = confirmedDetectionCount ?? 0;
|
|
const activeVisual = getDefectVisual(
|
|
activeDetection?.detection.class_name ?? '',
|
|
);
|
|
const ActiveIssueIcon = activeVisual.icon;
|
|
const activeDisplayName =
|
|
activeDetection?.detection.display_name ?? 'Detection';
|
|
const discardMutation = useDiscardDetectionMutation(
|
|
ticketId,
|
|
videoId ?? undefined,
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (confirmedDetectionCount === undefined) return;
|
|
|
|
if (confirmedDetectionCount === 0 && currentIndex !== 0) {
|
|
setCurrentIndex(0);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
confirmedDetectionCount > 0 &&
|
|
currentIndex > confirmedDetectionCount - 1
|
|
) {
|
|
setCurrentIndex(confirmedDetectionCount - 1);
|
|
}
|
|
}, [confirmedDetectionCount, currentIndex]);
|
|
|
|
useEffect(() => {
|
|
onDetectionCountChange?.(confirmedDetectionCount);
|
|
}, [confirmedDetectionCount, onDetectionCountChange]);
|
|
|
|
const isNavigationDisabled =
|
|
detectionCount === 0 || detectionsQuery.isLoading;
|
|
const isInitialLoading = detectionsQuery.isLoading;
|
|
const isError = detectionsQuery.isError;
|
|
const retry = () => void detectionsQuery.refetch();
|
|
|
|
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 self-start items-center justify-center rounded-lg',
|
|
activeVisual.cardClassName,
|
|
)}
|
|
>
|
|
<ActiveIssueIcon
|
|
className={cn('size-5', activeVisual.colorClassName)}
|
|
/>
|
|
</span>
|
|
<div className="min-w-0">
|
|
<h3>{activeDisplayName}</h3>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 self-start sm:self-auto">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() =>
|
|
setCurrentIndex((index) =>
|
|
detectionCount === 0
|
|
? 0
|
|
: (index - 1 + detectionCount) % detectionCount,
|
|
)
|
|
}
|
|
disabled={isNavigationDisabled}
|
|
aria-label="Previous detection"
|
|
>
|
|
<ChevronLeft className="size-4" />
|
|
</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="secondary"
|
|
onClick={() =>
|
|
setCurrentIndex((index) =>
|
|
detectionCount === 0 ? 0 : (index + 1) % detectionCount,
|
|
)
|
|
}
|
|
disabled={isNavigationDisabled}
|
|
aria-label="Next detection"
|
|
>
|
|
<ChevronRight className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{isInitialLoading && !activeDetection ? (
|
|
<TicketDetectionPreviewSkeleton />
|
|
) : 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={retry}>
|
|
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">
|
|
{detectionCount > 0
|
|
? 'The selected detection details are unavailable. Try refreshing the preview.'
|
|
: isUnassignedPreview
|
|
? 'No unassigned issues remain on this ticket.'
|
|
: '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-muted-foreground">Detection Image</p>
|
|
<div className="w-full overflow-hidden rounded-lg">
|
|
<AnnotatedDetectionImage
|
|
detection={activeDetection}
|
|
videoWidth={detectionResult.video_width}
|
|
videoHeight={detectionResult.video_height}
|
|
aspectRatio="16 / 9"
|
|
enableLightbox
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DetectionLocationMap
|
|
className="min-w-0"
|
|
latitude={activeDetection.location.latitude}
|
|
longitude={activeDetection.location.longitude}
|
|
label={`${activeDetection.detection.display_name} - Frame ${activeDetection.frame.number}`}
|
|
title="Location on Map"
|
|
variant="plain"
|
|
aspectRatio="16 / 9"
|
|
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`,
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{isUnassignedPreview ? (
|
|
<>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={() => setIsDiscardDialogOpen(true)}
|
|
>
|
|
<Trash2 />
|
|
Discard detection
|
|
</Button>
|
|
</div>
|
|
|
|
<DetectionDiscardDialog
|
|
open={isDiscardDialogOpen}
|
|
detectionLabel={activeDetection.detection.display_name}
|
|
isPending={discardMutation.isPending}
|
|
onOpenChange={setIsDiscardDialogOpen}
|
|
onSubmit={async (payload) => {
|
|
await discardMutation.mutateAsync({
|
|
detectionId: activeDetection.detection.id,
|
|
payload,
|
|
});
|
|
setIsDiscardDialogOpen(false);
|
|
}}
|
|
/>
|
|
</>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|