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;
}