feat(ticket): add issues map to ticket assignment page

This commit is contained in:
2026-07-28 12:19:38 +05:30
parent 20f611f35a
commit 0601322361
9 changed files with 519 additions and 48 deletions

View File

@@ -0,0 +1,322 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import dynamic from 'next/dynamic';
import { AlertTriangle, MapPinned, RefreshCw } from 'lucide-react';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import type {
DetectionCoordinateClass,
DetectionCoordinateItem,
DetectionCoordinatesResponse,
} from '@/types';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
type AssignmentIssuesMapProps = {
data?: DetectionCoordinatesResponse;
isLoading: boolean;
isFetchingMore: boolean;
isError: boolean;
hasMore: boolean;
onLoadMore: () => void;
onRetry: () => void;
};
type ClientAssignmentIssuesMapProps = {
items: DetectionCoordinateItem[];
classes: DetectionCoordinateClass[];
selectedDetectionId: number | null;
onSelectDetection: (detectionId: number) => void;
};
function isValidCoordinate(item: DetectionCoordinateItem) {
return (
Number.isFinite(item.latitude) &&
Number.isFinite(item.longitude) &&
item.latitude >= -90 &&
item.latitude <= 90 &&
item.longitude >= -180 &&
item.longitude <= 180
);
}
const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
async () => {
const { CircleMarker, MapContainer, Popup, TileLayer, useMap } =
await import('react-leaflet');
function FitMapToIssues({ items }: { items: DetectionCoordinateItem[] }) {
const map = useMap();
useEffect(() => {
if (items.length === 0) return;
if (items.length === 1) {
map.setView([items[0].latitude, items[0].longitude], 17);
return;
}
map.fitBounds(
items.map(
(item) => [item.latitude, item.longitude] as [number, number],
),
{
padding: [32, 32],
maxZoom: 17,
},
);
}, [items, map]);
return null;
}
return function ClientAssignmentIssuesMapInner({
items,
classes,
selectedDetectionId,
onSelectDetection,
}: ClientAssignmentIssuesMapProps) {
const displayNames = new Map(
classes.map((item) => [item.class_name, item.display_name]),
);
const initialCenter: [number, number] = [
items[0].latitude,
items[0].longitude,
];
return (
<MapContainer
center={initialCenter}
zoom={15}
scrollWheelZoom
className="h-full w-full"
>
<TileLayer
attribution="&copy; OpenStreetMap contributors"
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<FitMapToIssues items={items} />
{items.map((item) => {
const visual = getDefectVisual(item.class_name);
const IssueIcon = visual.icon;
const isSelected = selectedDetectionId === item.id;
const displayName =
displayNames.get(item.class_name) ??
item.class_name.replaceAll('_', ' ');
return (
<CircleMarker
key={item.id}
center={[item.latitude, item.longitude]}
radius={isSelected ? 11 : 8}
pathOptions={{
color: isSelected ? '#ffffff' : visual.boundingBoxColor,
fillColor: visual.boundingBoxColor,
fillOpacity: isSelected ? 1 : 0.82,
weight: isSelected ? 4 : 2,
}}
eventHandlers={{
click: () => onSelectDetection(item.id),
}}
>
<Popup>
<div className="min-w-52 space-y-3">
<div className="flex items-center gap-2">
<span
className="flex size-8 items-center justify-center rounded-full"
style={{
backgroundColor: `${visual.boundingBoxColor}1f`,
color: visual.boundingBoxColor,
}}
>
<IssueIcon className="size-4" />
</span>
<div>
<p className="font-semibold capitalize">
{displayName}
</p>
<p className="text-xs text-muted-foreground">
Detection #{item.id}
</p>
</div>
</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-muted-foreground">Confidence</dt>
<dd className="text-right font-medium">
{Math.round(item.confidence * 100)}%
</dd>
<dt className="text-muted-foreground">Latitude</dt>
<dd className="text-right font-mono text-xs">
{item.latitude.toFixed(6)}
</dd>
<dt className="text-muted-foreground">Longitude</dt>
<dd className="text-right font-mono text-xs">
{item.longitude.toFixed(6)}
</dd>
</dl>
</div>
</Popup>
</CircleMarker>
);
})}
</MapContainer>
);
};
},
{
ssr: false,
loading: () => <div className="size-full animate-pulse bg-muted" />,
},
);
export function AssignmentIssuesMap({
data,
isLoading,
isFetchingMore,
isError,
hasMore,
onLoadMore,
onRetry,
}: AssignmentIssuesMapProps) {
const [selectedDetectionId, setSelectedDetectionId] = useState<number | null>(
null,
);
const validItems = useMemo(
() => (data?.items ?? []).filter(isValidCoordinate),
[data?.items],
);
const total = data?.total ?? 0;
const loadedCount = Math.min(data?.items.length ?? 0, total);
useEffect(() => {
if (
selectedDetectionId !== null &&
!validItems.some((item) => item.id === selectedDetectionId)
) {
setSelectedDetectionId(null);
}
}, [selectedDetectionId, validItems]);
return (
<Card className="overflow-hidden">
<CardHeader className="border-b">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-secondary p-2">
<MapPinned className="size-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">Issues on Map</CardTitle>
<CardDescription>
Click a map point to view the issue details.
</CardDescription>
</div>
</div>
{data ? (
<span className="shrink-0 text-sm text-muted-foreground">
{validItems.length} mapped
</span>
) : null}
</div>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="h-[440px] animate-pulse bg-muted" />
) : isError ? (
<div className="flex min-h-72 flex-col items-center justify-center gap-3 px-6 text-center">
<AlertTriangle className="size-7 text-destructive" />
<div>
<p className="font-medium">Unable to load issue locations.</p>
<p className="mt-1 text-sm text-muted-foreground">
Check the connection and try again.
</p>
</div>
<Button type="button" variant="outline" onClick={onRetry}>
<RefreshCw />
Retry
</Button>
</div>
) : validItems.length === 0 ? (
<div className="flex min-h-72 flex-col items-center justify-center px-6 text-center">
<MapPinned className="size-7 text-muted-foreground" />
<p className="mt-3 font-medium">No issue locations available.</p>
<p className="mt-1 text-sm text-muted-foreground">
Detections without valid coordinates cannot be placed on the map.
</p>
</div>
) : (
<>
<div className="h-[440px] min-w-0 bg-muted">
<ClientAssignmentIssuesMap
items={validItems}
classes={data?.classes ?? []}
selectedDetectionId={selectedDetectionId}
onSelectDetection={setSelectedDetectionId}
/>
</div>
<div
className="flex flex-wrap items-center gap-x-4 gap-y-2 border-t px-4 py-3"
aria-label="Map legend"
>
<span className="text-sm font-medium">Map legend</span>
{(data?.classes ?? []).map((item) => {
const visual = getDefectVisual(item.class_name);
const IssueIcon = visual.icon;
return (
<div
key={item.class_name}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<span
className="flex size-6 items-center justify-center rounded-full"
style={{
backgroundColor: `${visual.boundingBoxColor}1f`,
color: visual.boundingBoxColor,
}}
>
<IssueIcon className="size-3.5" />
</span>
<span>{item.display_name}</span>
</div>
);
})}
</div>
<div className="flex items-center justify-between gap-3 border-t px-4 py-3">
<span className="text-sm text-muted-foreground">
Showing {loadedCount} of {total}
</span>
{hasMore ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={onLoadMore}
disabled={isFetchingMore}
>
{isFetchingMore ? 'Loading...' : 'Load more issues'}
</Button>
) : (
<span className="text-sm text-muted-foreground">
All issues are shown
</span>
)}
</div>
</>
)}
</CardContent>
</Card>
);
}

View File

@@ -7,11 +7,16 @@ import { MultiSelectPopover } from '@/components/form/MultiSelectPopover';
import { Input } from '@/components/ui/input';
import { useDebounce } from '@/hooks/useDebounce';
import { useTicketDetectionsQuery } from '../../../hooks/useTicketQueries';
import {
useDetectionCoordinatesQuery,
useTicketDetectionsQuery,
} from '../../../hooks/useTicketQueries';
import { AssignmentIssuesMap } from './AssignmentIssuesMap';
import { useIssueColumns } from './IssueColumns';
import { IssueTable } from './IssueTable';
const DEFAULT_PAGE_SIZE = 10;
const MAP_PAGE_SIZE = 50;
interface IssueTypeOption {
class_name: string;
@@ -59,16 +64,40 @@ export function ChooseIssuesSection({
[debouncedSearch, limit, selectedIssueTypes, skip],
);
const detectionsQuery = useTicketDetectionsQuery(videoId, queryParams);
const mapQueryParams = useMemo(
() => ({
limit: MAP_PAGE_SIZE,
class_name:
selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined,
search: debouncedSearch || undefined,
}),
[debouncedSearch, selectedIssueTypes],
);
const coordinatesQuery = useDetectionCoordinatesQuery(
videoId,
mapQueryParams,
);
const result = detectionsQuery.data;
const mapData = useMemo(() => {
const pages = coordinatesQuery.data?.pages;
if (!pages?.length) return undefined;
const latestPage = pages[pages.length - 1];
return {
...latestPage,
items: pages.flatMap((page) => page.items),
truncated: Boolean(coordinatesQuery.hasNextPage),
};
}, [coordinatesQuery.data?.pages, coordinatesQuery.hasNextPage]);
return (
<IssueTable
columns={columns}
issues={result?.items ?? []}
isLoading={detectionsQuery.isLoading || detectionsQuery.isFetching}
isError={detectionsQuery.isError}
toolbar={
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="min-w-0 space-y-5">
<div
aria-label="Issue filters"
className="flex flex-col gap-3 sm:flex-row sm:items-center"
>
<div className="w-fit">
<MultiSelectPopover
label="Issue Types"
@@ -80,9 +109,11 @@ export function ChooseIssuesSection({
align="start"
/>
</div>
<div className="relative w-full sm:ml-auto sm:w-72">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="assignment-issue-search"
value={detectionSearch}
onChange={(event) => setDetectionSearch(event.target.value)}
placeholder="Search by detection ID"
@@ -91,12 +122,29 @@ export function ChooseIssuesSection({
/>
</div>
</div>
}
<AssignmentIssuesMap
data={mapData}
isLoading={coordinatesQuery.isLoading}
isFetchingMore={coordinatesQuery.isFetchingNextPage}
isError={coordinatesQuery.isError}
hasMore={Boolean(coordinatesQuery.hasNextPage)}
onLoadMore={() => void coordinatesQuery.fetchNextPage()}
onRetry={() => void coordinatesQuery.refetch()}
/>
<IssueTable
columns={columns}
issues={result?.items ?? []}
isLoading={detectionsQuery.isLoading || detectionsQuery.isFetching}
isError={detectionsQuery.isError}
toolbar={null}
skip={skip}
limit={limit}
total={result?.total ?? 0}
onPageChange={setSkip}
onLimitChange={setLimit}
/>
</div>
);
}

View File

@@ -12,10 +12,8 @@ import { AssignTicketAction } from './actions/AssignTicketAction';
export function TicketDetailHeader({
ticket,
canAssign = false,
}: {
ticket: TicketOverviewDetail;
canAssign?: boolean;
}) {
const router = useRouter();
const ticketLabel = ticket.ticket_name || ticket.id;
@@ -28,11 +26,9 @@ export function TicketDetailHeader({
</div>
<div className="flex shrink-0 items-center gap-2">
{canAssign ? (
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<AssignTicketAction ticketId={ticket.id} />
</PermissionGuard>
) : null}
<Button variant="secondary" onClick={() => router.push('/ticket')}>
<ArrowLeft className="size-4" />
Back to List

View File

@@ -61,10 +61,7 @@ export default function TicketDetailPage() {
<main className="relative z-10 space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
<div className="xl:shrink-0">
{overview ? (
<TicketDetailHeader
ticket={overview}
canAssign={classDetail?.status === 'unassigned'}
/>
<TicketDetailHeader ticket={overview} />
) : (
<TicketOverviewHeaderSkeleton />
)}

View File

@@ -13,6 +13,7 @@ import { detectionService, ticketService, videoService } from '@/services/api';
import type {
AssignTicketPayload,
CloseTicketPayload,
DetectionCoordinatesParams,
DiscardDetectionPayload,
RequestTicketExtensionPayload,
ReviewDetectionRepairProofPayload,
@@ -122,6 +123,42 @@ export function useTicketDetectionsQuery(
});
}
export function useDetectionCoordinatesQuery(
videoId: string | undefined,
params: DetectionCoordinatesParams,
) {
const queryParams = useMemo<DetectionCoordinatesParams>(
() => ({
limit: params.limit ?? 50,
class_name: params.class_name,
search: params.search,
}),
[params.class_name, params.limit, params.search],
);
return useInfiniteQuery({
queryKey: ticketKeys.detectionCoordinates(videoId ?? '', queryParams),
queryFn: ({ pageParam }) =>
detectionService.getCoordinates(videoId as string, {
...queryParams,
skip: pageParam,
}),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (lastPage.items.length === 0) return undefined;
const loadedCount = allPages.reduce(
(total, page) => total + page.items.length,
0,
);
return loadedCount < lastPage.total ? loadedCount : undefined;
},
enabled: Boolean(videoId),
staleTime: 60 * 1000,
});
}
export function useTicketClassDetectionsQuery(
videoId: string | undefined,
params: VideoDetectionsParams,

View File

@@ -1,4 +1,8 @@
import type { TicketListParams, VideoDetectionsParams } from '@/types';
import type {
DetectionCoordinatesParams,
TicketListParams,
VideoDetectionsParams,
} from '@/types';
export const ticketKeys = {
all: ['tickets'] as const,
@@ -13,6 +17,14 @@ export const ticketKeys = {
[...ticketKeys.details(), 'video', videoId, 'detections'] as const,
classDetections: (videoId: string, params: VideoDetectionsParams) =>
[...ticketKeys.classDetectionLists(videoId), params] as const,
detectionCoordinates: (videoId: string, params: DetectionCoordinatesParams) =>
[
...ticketKeys.details(),
'video',
videoId,
'detection-coordinates',
params,
] as const,
classReviewDetections: (
videoId: string,
defectClass: string,

View File

@@ -57,6 +57,7 @@ export const API_ROUTES = {
ANNOTATION_FRAMES: (id: string) =>
`/biz/api/v1/results/${id}/annotation-frames`,
DETECTIONS: (id: string) => `/biz/api/v1/results/${id}/detections`,
COORDINATES: (id: string) => `/biz/api/v1/results/${id}/coordinates`,
},
DETECTIONS: {
DISCARD: (id: number) => `/biz/api/v1/detections/${id}/discard`,

View File

@@ -1,5 +1,7 @@
import { API_ROUTES } from '@/constants/apiRoutes';
import type {
DetectionCoordinatesParams,
DetectionCoordinatesResponse,
DiscardDetectionPayload,
DiscardDetectionResponse,
ReviewDetectionRepairProofPayload,
@@ -9,6 +11,33 @@ import type {
import axiosClient from '../axios/axios';
export const detectionService = {
getCoordinates: async (
videoId: string,
params?: DetectionCoordinatesParams,
): Promise<DetectionCoordinatesResponse> => {
const searchParams = new URLSearchParams();
params?.class_name?.forEach((className) => {
searchParams.append('class_name', className);
});
if (params?.skip !== undefined) {
searchParams.set('skip', String(params.skip));
}
if (params?.limit !== undefined) {
searchParams.set('limit', String(params.limit));
}
if (params?.search) {
searchParams.set('search', params.search);
}
const response = await axiosClient.get<DetectionCoordinatesResponse>(
API_ROUTES.VIDEOS.COORDINATES(videoId),
{ params: searchParams },
);
return response.data;
},
discardDetection: async (
detectionId: number,
payload: DiscardDetectionPayload,

View File

@@ -5,6 +5,35 @@ export type DetectionClassCount = {
unique_count: number;
};
export type DetectionCoordinateClass = {
class_name: string;
display_name: string;
count: number;
};
export type DetectionCoordinateItem = {
id: number;
class_name: string;
latitude: number;
longitude: number;
confidence: number;
};
export type DetectionCoordinatesParams = {
skip?: number;
limit?: number;
class_name?: string[];
search?: string;
};
export type DetectionCoordinatesResponse = {
total: number;
limit: number;
truncated: boolean;
classes: DetectionCoordinateClass[];
items: DetectionCoordinateItem[];
};
export type DetectionBoundingBox = {
x1: number;
y1: number;