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 { interface AssignTicketFormProps {
ticketId: string; ticketId: string;
videoId?: string;
selectedClassNames: string[]; selectedClassNames: string[];
startPoint: AssignmentRangePoint | null; startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null; endPoint: AssignmentRangePoint | null;
@@ -37,7 +36,6 @@ interface AssignTicketFormProps {
export function AssignTicketForm({ export function AssignTicketForm({
ticketId, ticketId,
videoId,
selectedClassNames, selectedClassNames,
startPoint, startPoint,
endPoint, endPoint,
@@ -49,7 +47,7 @@ export function AssignTicketForm({
const [dueDate, setDueDate] = useState<Date>(); const [dueDate, setDueDate] = useState<Date>();
const [assignNote, setAssignNote] = useState(''); const [assignNote, setAssignNote] = useState('');
const assignMutation = useAssignTicketMutation(ticketId, videoId); const assignMutation = useAssignTicketMutation(ticketId);
const today = useMemo(() => { const today = useMemo(() => {
const date = new Date(); const date = new Date();
date.setHours(0, 0, 0, 0); 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 { AlertTriangle, MapPinned, RefreshCw } from 'lucide-react';
import { getDefectVisual } from '@/constants/defectVisualConfig'; import { getDefectVisual } from '@/constants/defectVisualConfig';
import type { DetectionResultItem, VideoDetectionsResponse } from '@/types'; import type {
TicketAssignmentDetectionItem,
TicketAssignmentDetectionsResponse,
} from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Card, Card,
@@ -18,7 +21,7 @@ import {
import type { AssignmentRangePoint } from './assignmentRange'; import type { AssignmentRangePoint } from './assignmentRange';
type AssignmentIssuesMapProps = { type AssignmentIssuesMapProps = {
data?: VideoDetectionsResponse; data?: TicketAssignmentDetectionsResponse;
isLoading: boolean; isLoading: boolean;
isError: boolean; isError: boolean;
startPoint: AssignmentRangePoint | null; startPoint: AssignmentRangePoint | null;
@@ -32,7 +35,6 @@ type AssignmentMapItem = {
display_name: string; display_name: string;
latitude: number; latitude: number;
longitude: number; longitude: number;
confidence: number;
sequence: number; sequence: number;
}; };
@@ -50,7 +52,10 @@ type ClientAssignmentIssuesMapProps = {
onSelectDetection: (detectionId: number) => void; onSelectDetection: (detectionId: number) => void;
}; };
function toMapItem(item: DetectionResultItem): AssignmentMapItem | null { function toMapItem(
item: TicketAssignmentDetectionItem,
sequence: number,
): AssignmentMapItem | null {
const { latitude, longitude } = item.location; const { latitude, longitude } = item.location;
if ( if (
typeof latitude !== 'number' || typeof latitude !== 'number' ||
@@ -71,8 +76,7 @@ function toMapItem(item: DetectionResultItem): AssignmentMapItem | null {
display_name: item.detection.display_name, display_name: item.detection.display_name,
latitude, latitude,
longitude, longitude,
confidence: item.detection.confidence, sequence,
sequence: item.frame.timestamp_seconds,
}; };
} }
@@ -263,12 +267,6 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
</div> </div>
<dl className="!m-0 space-y-2 py-3 text-sm"> <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"> <div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">Latitude</dt> <dt className="text-muted-foreground">Latitude</dt>
<dd className="!m-0 font-mono text-xs tabular-nums"> <dd className="!m-0 font-mono text-xs tabular-nums">
@@ -321,8 +319,8 @@ export function AssignmentIssuesMap({
); );
const validItems = useMemo( const validItems = useMemo(
() => () =>
(data?.items ?? []).flatMap((item) => { (data?.items ?? []).flatMap((item, index) => {
const mapItem = toMapItem(item); const mapItem = toMapItem(item, index);
return mapItem ? [mapItem] : []; return mapItem ? [mapItem] : [];
}), }),
[data?.items], [data?.items],

View File

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

View File

@@ -5,17 +5,11 @@ import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { getDefectVisual } from '@/constants/defectVisualConfig'; import { getDefectVisual } from '@/constants/defectVisualConfig';
import type { DetectionResultItem } from '@/types'; import type { TicketAssignmentDetectionItem } from '@/types';
import type { AssignmentRangePoint } from './assignmentRange'; import type { AssignmentRangePoint } from './assignmentRange';
import { AssignmentRangeActions } from './AssignmentRangeActions'; 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 { interface UseIssueColumnsOptions {
startPoint: AssignmentRangePoint | null; startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null; endPoint: AssignmentRangePoint | null;
@@ -28,7 +22,7 @@ export function useIssueColumns({
endPoint, endPoint,
onSetStart, onSetStart,
onSetEnd, onSetEnd,
}: UseIssueColumnsOptions): ColumnDef<DetectionResultItem>[] { }: UseIssueColumnsOptions): ColumnDef<TicketAssignmentDetectionItem>[] {
return useMemo( 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', id: 'range_actions',
header: 'Set range', header: 'Set range',

View File

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

View File

@@ -45,7 +45,6 @@ export default function TicketAssignmentPage() {
const [assignmentRevision, setAssignmentRevision] = useState(0); const [assignmentRevision, setAssignmentRevision] = useState(0);
const overviewQuery = useTicketOverviewQuery(ticketId); const overviewQuery = useTicketOverviewQuery(ticketId);
const overview = overviewQuery.data; const overview = overviewQuery.data;
const videoId = overview?.video_id ?? overview?.video?.id ?? undefined;
const ticketDetailUrl = ROUTES.TICKET_DETAIL(ticketId); const ticketDetailUrl = ROUTES.TICKET_DETAIL(ticketId);
const backToTicket = () => router.push(ticketDetailUrl); 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="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"> <div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
<ChooseIssuesSection <ChooseIssuesSection
videoId={videoId} ticketId={ticketId}
issueTypes={overview.ai_result.detections_by_class} issueTypes={overview.ai_result.detections_by_class}
selectedIssueTypes={selectedIssueTypes} selectedIssueTypes={selectedIssueTypes}
cacheRevision={assignmentRevision} cacheRevision={assignmentRevision}
@@ -146,7 +145,6 @@ export default function TicketAssignmentPage() {
> >
<AssignTicketForm <AssignTicketForm
ticketId={ticketId} ticketId={ticketId}
videoId={videoId}
selectedClassNames={selectedIssueTypes} selectedClassNames={selectedIssueTypes}
startPoint={startPoint} startPoint={startPoint}
endPoint={endPoint} endPoint={endPoint}

View File

@@ -17,6 +17,7 @@ import type {
RequestTicketExtensionPayload, RequestTicketExtensionPayload,
ReviewDetectionRepairProofPayload, ReviewDetectionRepairProofPayload,
TicketAssignmentSummary, TicketAssignmentSummary,
TicketAssignmentDetectionsParams,
TicketListParams, TicketListParams,
DetectionProofStatus, DetectionProofStatus,
VideoDetectionsParams, VideoDetectionsParams,
@@ -136,6 +137,32 @@ export function useTicketClassDetectionsQuery(
return useTicketDetectionsQuery(videoId, params); 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( export function useTicketReviewDetectionsQuery(
videoId: string | undefined, videoId: string | undefined,
proofStatus?: DetectionProofStatus, 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(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
@@ -278,13 +305,12 @@ export function useAssignTicketMutation(ticketId: string, videoId?: string) {
queryKey: ticketKeys.timeline(ticketId), queryKey: ticketKeys.timeline(ticketId),
}), }),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }), queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
...(videoId
? [
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.classDetectionLists(videoId), queryKey: ticketKeys.assignmentDetectionLists(ticketId),
}),
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
}), }),
]
: []),
]); ]);
}, },
onError: () => toast.error('Failed to assign ticket'), 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 = { export const ticketKeys = {
all: ['tickets'] as const, all: ['tickets'] as const,
@@ -19,6 +23,12 @@ export const ticketKeys = {
'review', 'review',
proofStatus ?? 'all', proofStatus ?? 'all',
] as const, ] 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) => assignments: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'assignments'] as const, [...ticketKeys.details(), ticketId, 'assignments'] as const,
extensionRequest: (ticketId: string, assignmentId: number | undefined) => extensionRequest: (ticketId: string, assignmentId: number | undefined) =>

View File

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

View File

@@ -9,6 +9,8 @@ import type {
ReviewTicketExtensionPayload, ReviewTicketExtensionPayload,
SseTokenResponse, SseTokenResponse,
TicketAssignmentSummary, TicketAssignmentSummary,
TicketAssignmentDetectionsParams,
TicketAssignmentDetectionsResponse,
TicketAssignmentsResponse, TicketAssignmentsResponse,
TicketExtensionRequestsResponse, TicketExtensionRequestsResponse,
TicketExtensionRequestsParams, TicketExtensionRequestsParams,
@@ -52,6 +54,27 @@ export const ticketService = {
return response.data; 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 ( getTicketTimeline: async (
ticketId: string, ticketId: string,
): Promise<TicketTimelineResponse> => { ): Promise<TicketTimelineResponse> => {

View File

@@ -104,6 +104,37 @@ export type VideoDetectionsResponse = {
items: DetectionResultItem[]; 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 = export type DetectionDiscardReason =
| 'not_a_defect' | 'not_a_defect'
| 'wrong_class' | 'wrong_class'