feat(ticket): use assignment detections API for unassigned issues

This commit is contained in:
2026-08-04 15:46:22 +05:30
parent 6169bbdc28
commit bad9c50dbf
11 changed files with 132 additions and 56 deletions

View File

@@ -28,7 +28,6 @@ import {
interface AssignTicketFormProps {
ticketId: string;
videoId?: string;
selectedClassNames: string[];
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
@@ -37,7 +36,6 @@ interface AssignTicketFormProps {
export function AssignTicketForm({
ticketId,
videoId,
selectedClassNames,
startPoint,
endPoint,
@@ -49,7 +47,7 @@ export function AssignTicketForm({
const [dueDate, setDueDate] = useState<Date>();
const [assignNote, setAssignNote] = useState('');
const assignMutation = useAssignTicketMutation(ticketId, videoId);
const assignMutation = useAssignTicketMutation(ticketId);
const today = useMemo(() => {
const date = new Date();
date.setHours(0, 0, 0, 0);

View File

@@ -5,7 +5,10 @@ import dynamic from 'next/dynamic';
import { AlertTriangle, MapPinned, RefreshCw } from 'lucide-react';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import type { DetectionResultItem, VideoDetectionsResponse } from '@/types';
import type {
TicketAssignmentDetectionItem,
TicketAssignmentDetectionsResponse,
} from '@/types';
import { Button } from '@/components/ui/button';
import {
Card,
@@ -18,7 +21,7 @@ import {
import type { AssignmentRangePoint } from './assignmentRange';
type AssignmentIssuesMapProps = {
data?: VideoDetectionsResponse;
data?: TicketAssignmentDetectionsResponse;
isLoading: boolean;
isError: boolean;
startPoint: AssignmentRangePoint | null;
@@ -32,7 +35,6 @@ type AssignmentMapItem = {
display_name: string;
latitude: number;
longitude: number;
confidence: number;
sequence: number;
};
@@ -50,7 +52,10 @@ type ClientAssignmentIssuesMapProps = {
onSelectDetection: (detectionId: number) => void;
};
function toMapItem(item: DetectionResultItem): AssignmentMapItem | null {
function toMapItem(
item: TicketAssignmentDetectionItem,
sequence: number,
): AssignmentMapItem | null {
const { latitude, longitude } = item.location;
if (
typeof latitude !== 'number' ||
@@ -71,8 +76,7 @@ function toMapItem(item: DetectionResultItem): AssignmentMapItem | null {
display_name: item.detection.display_name,
latitude,
longitude,
confidence: item.detection.confidence,
sequence: item.frame.timestamp_seconds,
sequence,
};
}
@@ -263,12 +267,6 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
</div>
<dl className="!m-0 space-y-2 py-3 text-sm">
<div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">Confidence</dt>
<dd className="!m-0 font-medium tabular-nums">
{Math.round(item.confidence * 100)}%
</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">Latitude</dt>
<dd className="!m-0 font-mono text-xs tabular-nums">
@@ -321,8 +319,8 @@ export function AssignmentIssuesMap({
);
const validItems = useMemo(
() =>
(data?.items ?? []).flatMap((item) => {
const mapItem = toMapItem(item);
(data?.items ?? []).flatMap((item, index) => {
const mapItem = toMapItem(item, index);
return mapItem ? [mapItem] : [];
}),
[data?.items],

View File

@@ -7,9 +7,9 @@ import { MultiSelectPopover } from '@/components/form/MultiSelectPopover';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { VideoDetectionsResponse } from '@/types';
import type { TicketAssignmentDetectionsResponse } from '@/types';
import { useTicketDetectionsQuery } from '../../../hooks/useTicketQueries';
import { useTicketAssignmentDetectionsQuery } from '../../../hooks/useTicketQueries';
import {
formatCompactRangeCoordinate,
formatRangeCoordinate,
@@ -28,7 +28,7 @@ interface IssueTypeOption {
}
interface ChooseIssuesSectionProps {
videoId?: string;
ticketId: string;
issueTypes: IssueTypeOption[];
selectedIssueTypes: string[];
cacheRevision: number;
@@ -40,7 +40,7 @@ interface ChooseIssuesSectionProps {
}
export function ChooseIssuesSection({
videoId,
ticketId,
issueTypes,
selectedIssueTypes,
cacheRevision,
@@ -101,22 +101,25 @@ export function ChooseIssuesSection({
const queryParams = useMemo(
() => ({
assignment_status: 'unassigned' as const,
skip,
limit,
class_name:
selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined,
sort: 'timestamp_asc',
}),
[limit, selectedIssueTypes, skip],
);
const detectionsQuery = useTicketDetectionsQuery(videoId, queryParams);
const detectionsQuery = useTicketAssignmentDetectionsQuery(
ticketId,
queryParams,
);
const result = detectionsQuery.data;
const mapCacheKey = useMemo(
() => JSON.stringify([videoId ?? '', selectedIssueTypes, cacheRevision]),
[cacheRevision, selectedIssueTypes, videoId],
() => JSON.stringify([ticketId, selectedIssueTypes, cacheRevision]),
[cacheRevision, selectedIssueTypes, ticketId],
);
const [mapCache, setMapCache] = useState<
Record<string, VideoDetectionsResponse>
Record<string, TicketAssignmentDetectionsResponse>
>({});
useEffect(() => {

View File

@@ -5,17 +5,11 @@ import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import type { DetectionResultItem } from '@/types';
import type { TicketAssignmentDetectionItem } from '@/types';
import type { AssignmentRangePoint } from './assignmentRange';
import { AssignmentRangeActions } from './AssignmentRangeActions';
function formatTimestamp(seconds: number) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
interface UseIssueColumnsOptions {
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
@@ -28,7 +22,7 @@ export function useIssueColumns({
endPoint,
onSetStart,
onSetEnd,
}: UseIssueColumnsOptions): ColumnDef<DetectionResultItem>[] {
}: UseIssueColumnsOptions): ColumnDef<TicketAssignmentDetectionItem>[] {
return useMemo(
() => [
{
@@ -89,13 +83,6 @@ export function useIssueColumns({
);
},
},
{
id: 'timestamp',
header: 'Timestamp',
size: 130,
cell: ({ row }) =>
formatTimestamp(row.original.frame.timestamp_seconds),
},
{
id: 'range_actions',
header: 'Set range',

View File

@@ -4,11 +4,11 @@ import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table';
import type { DetectionResultItem } from '@/types';
import type { TicketAssignmentDetectionItem } from '@/types';
interface IssueTableProps {
columns: ColumnDef<DetectionResultItem>[];
issues: DetectionResultItem[];
columns: ColumnDef<TicketAssignmentDetectionItem>[];
issues: TicketAssignmentDetectionItem[];
isLoading: boolean;
isError: boolean;
toolbar: ReactNode;

View File

@@ -45,7 +45,6 @@ export default function TicketAssignmentPage() {
const [assignmentRevision, setAssignmentRevision] = useState(0);
const overviewQuery = useTicketOverviewQuery(ticketId);
const overview = overviewQuery.data;
const videoId = overview?.video_id ?? overview?.video?.id ?? undefined;
const ticketDetailUrl = ROUTES.TICKET_DETAIL(ticketId);
const backToTicket = () => router.push(ticketDetailUrl);
@@ -107,7 +106,7 @@ export default function TicketAssignmentPage() {
<div className="grid min-w-0 items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-stretch xl:overflow-hidden">
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
<ChooseIssuesSection
videoId={videoId}
ticketId={ticketId}
issueTypes={overview.ai_result.detections_by_class}
selectedIssueTypes={selectedIssueTypes}
cacheRevision={assignmentRevision}
@@ -146,7 +145,6 @@ export default function TicketAssignmentPage() {
>
<AssignTicketForm
ticketId={ticketId}
videoId={videoId}
selectedClassNames={selectedIssueTypes}
startPoint={startPoint}
endPoint={endPoint}

View File

@@ -17,6 +17,7 @@ import type {
RequestTicketExtensionPayload,
ReviewDetectionRepairProofPayload,
TicketAssignmentSummary,
TicketAssignmentDetectionsParams,
TicketListParams,
DetectionProofStatus,
VideoDetectionsParams,
@@ -136,6 +137,32 @@ export function useTicketClassDetectionsQuery(
return useTicketDetectionsQuery(videoId, params);
}
export function useTicketAssignmentDetectionsQuery(
ticketId: string | undefined,
params: TicketAssignmentDetectionsParams,
) {
const queryParams = useMemo(
() => ({
assignment_status: params.assignment_status ?? 'unassigned',
class_name: params.class_name,
skip: params.skip ?? 0,
limit: params.limit ?? 500,
}),
[params.assignment_status, params.class_name, params.limit, params.skip],
);
return useQuery({
queryKey: ticketKeys.assignmentDetections(ticketId ?? '', queryParams),
queryFn: () =>
ticketService.getTicketAssignmentDetections(
ticketId as string,
queryParams,
),
enabled: Boolean(ticketId),
staleTime: Infinity,
});
}
export function useTicketReviewDetectionsQuery(
videoId: string | undefined,
proofStatus?: DetectionProofStatus,
@@ -262,7 +289,7 @@ export function useAssignableTicketUsersQuery(enabled: boolean) {
});
}
export function useAssignTicketMutation(ticketId: string, videoId?: string) {
export function useAssignTicketMutation(ticketId: string) {
const queryClient = useQueryClient();
return useMutation({
@@ -278,13 +305,12 @@ export function useAssignTicketMutation(ticketId: string, videoId?: string) {
queryKey: ticketKeys.timeline(ticketId),
}),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
...(videoId
? [
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetectionLists(videoId),
}),
]
: []),
queryClient.invalidateQueries({
queryKey: ticketKeys.assignmentDetectionLists(ticketId),
}),
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
}),
]);
},
onError: () => toast.error('Failed to assign ticket'),

View File

@@ -1,4 +1,8 @@
import type { TicketListParams, VideoDetectionsParams } from '@/types';
import type {
TicketAssignmentDetectionsParams,
TicketListParams,
VideoDetectionsParams,
} from '@/types';
export const ticketKeys = {
all: ['tickets'] as const,
@@ -19,6 +23,12 @@ export const ticketKeys = {
'review',
proofStatus ?? 'all',
] as const,
assignmentDetectionLists: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'assignment-detections'] as const,
assignmentDetections: (
ticketId: string,
params: TicketAssignmentDetectionsParams,
) => [...ticketKeys.assignmentDetectionLists(ticketId), params] as const,
assignments: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'assignments'] as const,
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>

View File

@@ -64,6 +64,8 @@ export const API_ROUTES = {
TICKETS: {
BASE: '/biz/api/v1/tickets',
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
ASSIGNMENT_DETECTIONS: (id: string) =>
`/biz/api/v1/tickets/${id}/assignment-detections`,
TIMELINE: (id: string) => `/biz/api/v1/tickets/${id}/timeline`,
ASSIGNMENTS: (id: string) => `/biz/api/v1/tickets/${id}/assignments`,
REQUEST_EXTENSION: (ticketId: string, assignmentId: number) =>

View File

@@ -9,6 +9,8 @@ import type {
ReviewTicketExtensionPayload,
SseTokenResponse,
TicketAssignmentSummary,
TicketAssignmentDetectionsParams,
TicketAssignmentDetectionsResponse,
TicketAssignmentsResponse,
TicketExtensionRequestsResponse,
TicketExtensionRequestsParams,
@@ -52,6 +54,27 @@ export const ticketService = {
return response.data;
},
getTicketAssignmentDetections: async (
ticketId: string,
params?: TicketAssignmentDetectionsParams,
): Promise<TicketAssignmentDetectionsResponse> => {
const response = await axiosClient.get<TicketAssignmentDetectionsResponse>(
API_ROUTES.TICKETS.ASSIGNMENT_DETECTIONS(ticketId),
{
params: {
assignment_status: params?.assignment_status ?? 'unassigned',
class_name: params?.class_name,
skip: params?.skip ?? 0,
limit: params?.limit ?? 500,
},
paramsSerializer: {
indexes: null,
},
},
);
return response.data;
},
getTicketTimeline: async (
ticketId: string,
): Promise<TicketTimelineResponse> => {

View File

@@ -104,6 +104,37 @@ export type VideoDetectionsResponse = {
items: DetectionResultItem[];
};
export type AssignmentDetectionStatus = 'assigned' | 'unassigned';
export type TicketAssignmentDetectionsParams = {
assignment_status?: AssignmentDetectionStatus;
class_name?: string | string[];
skip?: number;
limit?: number;
};
export type TicketAssignmentDetectionItem = {
id: string;
detection: {
id: number;
class_name: string;
display_name: string;
};
location: {
latitude: number;
longitude: number;
};
};
export type TicketAssignmentDetectionsResponse = {
ticket_id: string;
assignment_status: AssignmentDetectionStatus;
items: TicketAssignmentDetectionItem[];
total: number;
skip: number;
limit: number;
};
export type DetectionDiscardReason =
| 'not_a_defect'
| 'wrong_class'