feat(ticket): add issues map to ticket assignment page
This commit is contained in:
@@ -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="© 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,11 +7,16 @@ import { MultiSelectPopover } from '@/components/form/MultiSelectPopover';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useDebounce } from '@/hooks/useDebounce';
|
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 { useIssueColumns } from './IssueColumns';
|
||||||
import { IssueTable } from './IssueTable';
|
import { IssueTable } from './IssueTable';
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 10;
|
const DEFAULT_PAGE_SIZE = 10;
|
||||||
|
const MAP_PAGE_SIZE = 50;
|
||||||
|
|
||||||
interface IssueTypeOption {
|
interface IssueTypeOption {
|
||||||
class_name: string;
|
class_name: string;
|
||||||
@@ -59,44 +64,87 @@ export function ChooseIssuesSection({
|
|||||||
[debouncedSearch, limit, selectedIssueTypes, skip],
|
[debouncedSearch, limit, selectedIssueTypes, skip],
|
||||||
);
|
);
|
||||||
const detectionsQuery = useTicketDetectionsQuery(videoId, queryParams);
|
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 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 (
|
return (
|
||||||
<IssueTable
|
<div className="min-w-0 space-y-5">
|
||||||
columns={columns}
|
<div
|
||||||
issues={result?.items ?? []}
|
aria-label="Issue filters"
|
||||||
isLoading={detectionsQuery.isLoading || detectionsQuery.isFetching}
|
className="flex flex-col gap-3 sm:flex-row sm:items-center"
|
||||||
isError={detectionsQuery.isError}
|
>
|
||||||
toolbar={
|
<div className="w-fit">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<MultiSelectPopover
|
||||||
<div className="w-fit">
|
label="Issue Types"
|
||||||
<MultiSelectPopover
|
options={issueTypeOptions}
|
||||||
label="Issue Types"
|
values={selectedIssueTypes}
|
||||||
options={issueTypeOptions}
|
onValuesChange={setSelectedIssueTypes}
|
||||||
values={selectedIssueTypes}
|
searchPlaceholder="Search issue types"
|
||||||
onValuesChange={setSelectedIssueTypes}
|
emptyMessage="No issue types found."
|
||||||
searchPlaceholder="Search issue types"
|
align="start"
|
||||||
emptyMessage="No issue types found."
|
/>
|
||||||
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
|
|
||||||
value={detectionSearch}
|
|
||||||
onChange={(event) => setDetectionSearch(event.target.value)}
|
|
||||||
placeholder="Search by detection ID"
|
|
||||||
aria-label="Search by detection ID"
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
skip={skip}
|
<div className="relative w-full sm:ml-auto sm:w-72">
|
||||||
limit={limit}
|
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
total={result?.total ?? 0}
|
<Input
|
||||||
onPageChange={setSkip}
|
id="assignment-issue-search"
|
||||||
onLimitChange={setLimit}
|
value={detectionSearch}
|
||||||
/>
|
onChange={(event) => setDetectionSearch(event.target.value)}
|
||||||
|
placeholder="Search by detection ID"
|
||||||
|
aria-label="Search by detection ID"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ import { AssignTicketAction } from './actions/AssignTicketAction';
|
|||||||
|
|
||||||
export function TicketDetailHeader({
|
export function TicketDetailHeader({
|
||||||
ticket,
|
ticket,
|
||||||
canAssign = false,
|
|
||||||
}: {
|
}: {
|
||||||
ticket: TicketOverviewDetail;
|
ticket: TicketOverviewDetail;
|
||||||
canAssign?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const ticketLabel = ticket.ticket_name || ticket.id;
|
const ticketLabel = ticket.ticket_name || ticket.id;
|
||||||
@@ -28,11 +26,9 @@ export function TicketDetailHeader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{canAssign ? (
|
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
<AssignTicketAction ticketId={ticket.id} />
|
||||||
<AssignTicketAction ticketId={ticket.id} />
|
</PermissionGuard>
|
||||||
</PermissionGuard>
|
|
||||||
) : null}
|
|
||||||
<Button variant="secondary" onClick={() => router.push('/ticket')}>
|
<Button variant="secondary" onClick={() => router.push('/ticket')}>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
Back to List
|
Back to List
|
||||||
|
|||||||
@@ -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">
|
<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">
|
<div className="xl:shrink-0">
|
||||||
{overview ? (
|
{overview ? (
|
||||||
<TicketDetailHeader
|
<TicketDetailHeader ticket={overview} />
|
||||||
ticket={overview}
|
|
||||||
canAssign={classDetail?.status === 'unassigned'}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<TicketOverviewHeaderSkeleton />
|
<TicketOverviewHeaderSkeleton />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { detectionService, ticketService, videoService } from '@/services/api';
|
|||||||
import type {
|
import type {
|
||||||
AssignTicketPayload,
|
AssignTicketPayload,
|
||||||
CloseTicketPayload,
|
CloseTicketPayload,
|
||||||
|
DetectionCoordinatesParams,
|
||||||
DiscardDetectionPayload,
|
DiscardDetectionPayload,
|
||||||
RequestTicketExtensionPayload,
|
RequestTicketExtensionPayload,
|
||||||
ReviewDetectionRepairProofPayload,
|
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(
|
export function useTicketClassDetectionsQuery(
|
||||||
videoId: string | undefined,
|
videoId: string | undefined,
|
||||||
params: VideoDetectionsParams,
|
params: VideoDetectionsParams,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { TicketListParams, VideoDetectionsParams } from '@/types';
|
import type {
|
||||||
|
DetectionCoordinatesParams,
|
||||||
|
TicketListParams,
|
||||||
|
VideoDetectionsParams,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
export const ticketKeys = {
|
export const ticketKeys = {
|
||||||
all: ['tickets'] as const,
|
all: ['tickets'] as const,
|
||||||
@@ -13,6 +17,14 @@ export const ticketKeys = {
|
|||||||
[...ticketKeys.details(), 'video', videoId, 'detections'] as const,
|
[...ticketKeys.details(), 'video', videoId, 'detections'] as const,
|
||||||
classDetections: (videoId: string, params: VideoDetectionsParams) =>
|
classDetections: (videoId: string, params: VideoDetectionsParams) =>
|
||||||
[...ticketKeys.classDetectionLists(videoId), params] as const,
|
[...ticketKeys.classDetectionLists(videoId), params] as const,
|
||||||
|
detectionCoordinates: (videoId: string, params: DetectionCoordinatesParams) =>
|
||||||
|
[
|
||||||
|
...ticketKeys.details(),
|
||||||
|
'video',
|
||||||
|
videoId,
|
||||||
|
'detection-coordinates',
|
||||||
|
params,
|
||||||
|
] as const,
|
||||||
classReviewDetections: (
|
classReviewDetections: (
|
||||||
videoId: string,
|
videoId: string,
|
||||||
defectClass: string,
|
defectClass: string,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const API_ROUTES = {
|
|||||||
ANNOTATION_FRAMES: (id: string) =>
|
ANNOTATION_FRAMES: (id: string) =>
|
||||||
`/biz/api/v1/results/${id}/annotation-frames`,
|
`/biz/api/v1/results/${id}/annotation-frames`,
|
||||||
DETECTIONS: (id: string) => `/biz/api/v1/results/${id}/detections`,
|
DETECTIONS: (id: string) => `/biz/api/v1/results/${id}/detections`,
|
||||||
|
COORDINATES: (id: string) => `/biz/api/v1/results/${id}/coordinates`,
|
||||||
},
|
},
|
||||||
DETECTIONS: {
|
DETECTIONS: {
|
||||||
DISCARD: (id: number) => `/biz/api/v1/detections/${id}/discard`,
|
DISCARD: (id: number) => `/biz/api/v1/detections/${id}/discard`,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { API_ROUTES } from '@/constants/apiRoutes';
|
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||||
import type {
|
import type {
|
||||||
|
DetectionCoordinatesParams,
|
||||||
|
DetectionCoordinatesResponse,
|
||||||
DiscardDetectionPayload,
|
DiscardDetectionPayload,
|
||||||
DiscardDetectionResponse,
|
DiscardDetectionResponse,
|
||||||
ReviewDetectionRepairProofPayload,
|
ReviewDetectionRepairProofPayload,
|
||||||
@@ -9,6 +11,33 @@ import type {
|
|||||||
import axiosClient from '../axios/axios';
|
import axiosClient from '../axios/axios';
|
||||||
|
|
||||||
export const detectionService = {
|
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 (
|
discardDetection: async (
|
||||||
detectionId: number,
|
detectionId: number,
|
||||||
payload: DiscardDetectionPayload,
|
payload: DiscardDetectionPayload,
|
||||||
|
|||||||
@@ -5,6 +5,35 @@ export type DetectionClassCount = {
|
|||||||
unique_count: number;
|
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 = {
|
export type DetectionBoundingBox = {
|
||||||
x1: number;
|
x1: number;
|
||||||
y1: number;
|
y1: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user