From 7ffe3f0708938c96a6693c2b82849aa9b313f634 Mon Sep 17 00:00:00 2001 From: "santasri.pachhal" Date: Wed, 29 Jul 2026 12:16:27 +0530 Subject: [PATCH] feat(ticket): add assignment lots card to ticket detail --- .../components/TicketAssignmentsCard.tsx | 201 ++++++++++++++++++ src/app/(modules)/ticket/[ticketId]/page.tsx | 7 + .../ticket/hooks/useTicketDetailEvents.ts | 3 + .../ticket/hooks/useTicketQueries.ts | 23 ++ .../(modules)/ticket/queries/ticketKeys.ts | 2 + src/services/api/ticket.service.ts | 36 +++- src/types/ticket/actions.ts | 11 +- src/types/ticket/detail.ts | 24 +++ 8 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 src/app/(modules)/ticket/[ticketId]/components/TicketAssignmentsCard.tsx diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketAssignmentsCard.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketAssignmentsCard.tsx new file mode 100644 index 0000000..8d5850c --- /dev/null +++ b/src/app/(modules)/ticket/[ticketId]/components/TicketAssignmentsCard.tsx @@ -0,0 +1,201 @@ +'use client'; + +import { BriefcaseBusiness, CalendarClock, ListChecks } from 'lucide-react'; + +import { PersonInfo } from '@/components/person-avatar'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { getDefectVisual } from '@/constants/defectVisualConfig'; +import type { TicketActor, TicketAssignmentSummary } from '@/types'; +import { formatDate } from '@/utils/date'; + +import { useTicketAssignmentsQuery } from '../../hooks/useTicketQueries'; + +interface TicketAssignmentsCardProps { + ticketId: string; + initialAssignments?: TicketAssignmentSummary[]; +} + +function formatLabel(value: string) { + return value + .replaceAll('_', ' ') + .replace(/\b\w/g, (character) => character.toUpperCase()); +} + +function AssignmentClassBadge({ className }: { className: string }) { + const visual = getDefectVisual(className); + const IssueIcon = visual.icon; + + return ( + + + {formatLabel(className)} + + ); +} + +function AssignmentProgress({ + assignment, +}: { + assignment: TicketAssignmentSummary; +}) { + const total = Math.max(assignment.item_count, 0); + const approved = Math.max(assignment.approved_detections, 0); + const progress = total > 0 ? Math.min((approved / total) * 100, 100) : 0; + + return ( +
+
+ Repair progress + + {approved} of {total} approved + +
+ +
+ + {assignment.approved_detections} approved + + + {assignment.pending_detections} pending + + + {assignment.not_submitted_detections} not submitted + +
+
+ ); +} + +function AssignmentLot({ + assignment, +}: { + assignment: TicketAssignmentSummary; +}) { + const criteria = assignment.criteria; + const classNames = criteria?.class_names?.length + ? criteria.class_names + : assignment.defect_class + ? [assignment.defect_class] + : []; + const worker: TicketActor = { + user_id: assignment.assigned_to_user_id, + name: assignment.assigned_to_name, + email: assignment.assigned_to_email ?? null, + avatar_url: assignment.assigned_to_avatar_url ?? null, + }; + + return ( +
+
+

+ Lot {assignment.id} +

+ +
+ +
+ {classNames.map((className) => ( + + ))} + + + {assignment.item_count}{' '} + {assignment.item_count === 1 ? 'issue' : 'issues'} + + {assignment.due_at ? ( + + + Due {formatDate(assignment.due_at)} + + ) : null} +
+ + +
+ ); +} + +export function TicketAssignmentsCard({ + ticketId, + initialAssignments, +}: TicketAssignmentsCardProps) { + const assignmentsQuery = useTicketAssignmentsQuery( + ticketId, + initialAssignments, + ); + const assignments = assignmentsQuery.data?.items ?? []; + + return ( + + +
+
+ + + Assignment lots + + + Live ownership, criteria, and repair progress for this ticket. + +
+ {assignmentsQuery.data ? ( + {assignmentsQuery.data.total} + ) : null} +
+
+ + {assignmentsQuery.isLoading ? ( + Array.from({ length: 2 }).map((_, index) => ( +
+ )) + ) : assignmentsQuery.isError ? ( +
+

+ Unable to load assignment lots. +

+ +
+ ) : assignments.length ? ( + assignments.map((assignment) => ( + + )) + ) : ( +
+

No assignments yet

+

+ Assignment lots will appear here after work is allocated. +

+
+ )} + + + ); +} diff --git a/src/app/(modules)/ticket/[ticketId]/page.tsx b/src/app/(modules)/ticket/[ticketId]/page.tsx index 97abea1..6bb429b 100644 --- a/src/app/(modules)/ticket/[ticketId]/page.tsx +++ b/src/app/(modules)/ticket/[ticketId]/page.tsx @@ -16,6 +16,7 @@ import { } from './components/TicketClassDetailSkeleton'; import { TicketHistoryCard } from './components/TicketHistoryCard'; +import { TicketAssignmentsCard } from './components/TicketAssignmentsCard'; import { TicketOverviewCard } from './components/TicketOverviewCard'; import { TicketStatusActions } from './components/TicketStatusActions'; import { TicketDefectClassTabs } from './components/TicketDefectClassTabs'; @@ -74,6 +75,12 @@ export default function TicketDetailPage() { ) : ( )} + {overview ? ( + + ) : null} {isClassDetailLoading ? : null} {!isClassDetailLoading && classDetail ? ( diff --git a/src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts b/src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts index 978f4a6..823c023 100644 --- a/src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts +++ b/src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts @@ -32,6 +32,9 @@ export function useTicketDetailEvents( if (!ticketId) return; queryClient.refetchQueries({ queryKey: ticketKeys.overview(ticketId) }); + queryClient.refetchQueries({ + queryKey: ticketKeys.assignments(ticketId), + }); if (defectClassRef.current) { queryClient.refetchQueries({ queryKey: ticketKeys.classDetail(ticketId, defectClassRef.current), diff --git a/src/app/(modules)/ticket/hooks/useTicketQueries.ts b/src/app/(modules)/ticket/hooks/useTicketQueries.ts index 51ec16a..7c37315 100644 --- a/src/app/(modules)/ticket/hooks/useTicketQueries.ts +++ b/src/app/(modules)/ticket/hooks/useTicketQueries.ts @@ -19,6 +19,7 @@ import type { ReviewDetectionRepairProofPayload, ReviewTicketExtensionPayload, TicketDetail, + TicketAssignmentSummary, TicketListParams, DetectionProofStatus, VideoDetectionsParams, @@ -92,6 +93,25 @@ export function useTicketDefectClassesQuery(ticketId: string | undefined) { }); } +export function useTicketAssignmentsQuery( + ticketId: string | undefined, + initialAssignments?: TicketAssignmentSummary[], +) { + return useQuery({ + queryKey: ticketKeys.assignments(ticketId ?? ''), + queryFn: () => ticketService.getTicketAssignments(ticketId as string), + enabled: Boolean(ticketId), + initialData: + initialAssignments !== undefined + ? { + items: initialAssignments, + total: initialAssignments.length, + } + : undefined, + staleTime: 30 * 1000, + }); +} + export function useTicketDetectionsQuery( videoId: string | undefined, params: VideoDetectionsParams, @@ -231,6 +251,9 @@ export function useDiscardDetectionMutation( queryKey: ticketKeys.defectClasses(ticketId), }), queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }), + queryClient.invalidateQueries({ + queryKey: ticketKeys.assignments(ticketId), + }), ]); }, onError: () => toast.error('Failed to discard detection'), diff --git a/src/app/(modules)/ticket/queries/ticketKeys.ts b/src/app/(modules)/ticket/queries/ticketKeys.ts index 468fa49..73da6a0 100644 --- a/src/app/(modules)/ticket/queries/ticketKeys.ts +++ b/src/app/(modules)/ticket/queries/ticketKeys.ts @@ -39,6 +39,8 @@ export const ticketKeys = { ] as const, defectClasses: (ticketId: string) => [...ticketKeys.details(), ticketId, 'defect-classes'] as const, + assignments: (ticketId: string) => + [...ticketKeys.details(), ticketId, 'assignments'] as const, extensionRequest: (ticketId: string, assignmentId: number | undefined) => [ ...ticketKeys.details(), diff --git a/src/services/api/ticket.service.ts b/src/services/api/ticket.service.ts index f020bdf..4daad34 100644 --- a/src/services/api/ticket.service.ts +++ b/src/services/api/ticket.service.ts @@ -9,6 +9,8 @@ import type { ReviewTicketExtensionPayload, SseTokenResponse, TicketDetail, + TicketAssignmentSummary, + TicketAssignmentsResponse, TicketExtensionRequestsResponse, TicketOverviewDetail, TicketDefectClassesResponse, @@ -16,6 +18,14 @@ import type { TicketListResponse, } from '@/types'; +type TicketAssignmentsApiResponse = + | TicketAssignmentSummary[] + | TicketAssignmentsResponse + | { + assignments: TicketAssignmentSummary[]; + total?: number; + }; + export const ticketService = { getTickets: async ( params?: TicketListParams, @@ -80,14 +90,36 @@ export const ticketService = { assignTicket: async ( ticketId: string, payload: AssignTicketPayload, - ): Promise => { - const response = await axiosClient.post( + ): Promise => { + const response = await axiosClient.post( API_ROUTES.TICKETS.ASSIGNMENTS(ticketId), payload, ); return response.data; }, + getTicketAssignments: async ( + ticketId: string, + ): Promise => { + const response = await axiosClient.get( + API_ROUTES.TICKETS.ASSIGNMENTS(ticketId), + ); + const data = response.data; + + if (Array.isArray(data)) { + return { items: data, total: data.length }; + } + + if ('assignments' in data) { + return { + items: data.assignments, + total: data.total ?? data.assignments.length, + }; + } + + return data; + }, + requestTicketExtension: async ( ticketId: string, assignmentId: number, diff --git a/src/types/ticket/actions.ts b/src/types/ticket/actions.ts index 1ef3163..b05c72a 100644 --- a/src/types/ticket/actions.ts +++ b/src/types/ticket/actions.ts @@ -23,11 +23,12 @@ export interface AssignableTicketUsersResponse { } export interface TicketAssignmentCriteria { - class_names?: string[]; - start_lat?: number; - start_lng?: number; - end_lat?: number; - end_lng?: number; + class_names?: string[] | null; + start_lat?: number | null; + start_lng?: number | null; + end_lat?: number | null; + end_lng?: number | null; + min_confidence?: number | null; } export interface AssignTicketPayload { diff --git a/src/types/ticket/detail.ts b/src/types/ticket/detail.ts index 9595d6e..1c14d6b 100644 --- a/src/types/ticket/detail.ts +++ b/src/types/ticket/detail.ts @@ -1,5 +1,6 @@ import type { TicketDefectClassTicketStatus } from './defect-class'; import type { TicketStatus } from './status'; +import type { TicketAssignmentCriteria } from './actions'; export interface TicketActor { user_id: number | null; @@ -114,6 +115,28 @@ export interface TicketDetectionClassCount { count: number; } +export interface TicketAssignmentSummary { + id: number; + assigned_to_user_id: number; + assigned_to_name: string | null; + assigned_to_email?: string | null; + assigned_to_avatar_url?: string | null; + defect_class: string | null; + criteria: TicketAssignmentCriteria | null; + item_count: number; + approved_detections: number; + pending_detections: number; + not_submitted_detections: number; + is_complete: boolean; + due_at: string | null; + note?: string | null; +} + +export interface TicketAssignmentsResponse { + items: TicketAssignmentSummary[]; + total: number; +} + export interface TicketOverviewDetail { id: string; ticket_name?: string | null; @@ -134,6 +157,7 @@ export interface TicketOverviewDetail { available_defect_classes: string[]; detections_by_class: TicketDetectionClassCount[]; }; + assignments?: TicketAssignmentSummary[]; timestamps?: TicketTimestamps | null; }