feat(ticket): add assignment lots card to ticket detail

This commit is contained in:
2026-07-29 12:16:27 +05:30
parent 8e8803396d
commit 7ffe3f0708
8 changed files with 300 additions and 7 deletions

View File

@@ -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 (
<Badge
variant="outline"
className="h-6 gap-1.5 px-2 font-normal"
style={{
borderColor: `${visual.boundingBoxColor}66`,
backgroundColor: `${visual.boundingBoxColor}14`,
color: visual.boundingBoxColor,
}}
>
<IssueIcon />
{formatLabel(className)}
</Badge>
);
}
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 (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="font-medium text-foreground">Repair progress</span>
<span className="tabular-nums text-muted-foreground">
{approved} of {total} approved
</span>
</div>
<Progress value={assignment.is_complete ? 100 : progress} />
<div className="grid grid-cols-3 gap-2 text-xs">
<span className="text-emerald-600">
{assignment.approved_detections} approved
</span>
<span className="text-amber-600">
{assignment.pending_detections} pending
</span>
<span className="text-muted-foreground">
{assignment.not_submitted_detections} not submitted
</span>
</div>
</div>
);
}
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 (
<div className="space-y-4 rounded-lg border p-4">
<div className="min-w-0">
<p className="mb-2 text-xs font-medium text-muted-foreground">
Lot {assignment.id}
</p>
<PersonInfo person={worker} />
</div>
<div className="flex flex-wrap items-center gap-2">
{classNames.map((className) => (
<AssignmentClassBadge key={className} className={className} />
))}
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ListChecks className="size-3.5" />
{assignment.item_count}{' '}
{assignment.item_count === 1 ? 'issue' : 'issues'}
</span>
{assignment.due_at ? (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CalendarClock className="size-3.5" />
Due {formatDate(assignment.due_at)}
</span>
) : null}
</div>
<AssignmentProgress assignment={assignment} />
</div>
);
}
export function TicketAssignmentsCard({
ticketId,
initialAssignments,
}: TicketAssignmentsCardProps) {
const assignmentsQuery = useTicketAssignmentsQuery(
ticketId,
initialAssignments,
);
const assignments = assignmentsQuery.data?.items ?? [];
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<BriefcaseBusiness className="size-5 text-primary" />
Assignment lots
</CardTitle>
<CardDescription className="mt-1">
Live ownership, criteria, and repair progress for this ticket.
</CardDescription>
</div>
{assignmentsQuery.data ? (
<Badge variant="secondary">{assignmentsQuery.data.total}</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-3">
{assignmentsQuery.isLoading ? (
Array.from({ length: 2 }).map((_, index) => (
<div
key={index}
className="h-40 animate-pulse rounded-lg bg-muted"
/>
))
) : assignmentsQuery.isError ? (
<div className="rounded-lg border border-dashed p-6 text-center">
<p className="text-sm font-medium">
Unable to load assignment lots.
</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
onClick={() => void assignmentsQuery.refetch()}
>
Try again
</Button>
</div>
) : assignments.length ? (
assignments.map((assignment) => (
<AssignmentLot key={assignment.id} assignment={assignment} />
))
) : (
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
<p className="text-sm font-medium">No assignments yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Assignment lots will appear here after work is allocated.
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -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() {
) : (
<TicketOverviewCardSkeleton />
)}
{overview ? (
<TicketAssignmentsCard
ticketId={ticketId}
initialAssignments={overview.assignments}
/>
) : null}
<TicketDefectClassTabs ticketId={ticketId} ticket={overview} />
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
{!isClassDetailLoading && classDetail ? (

View File

@@ -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),

View File

@@ -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'),

View File

@@ -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(),

View File

@@ -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<TicketDetail> => {
const response = await axiosClient.post<TicketDetail>(
): Promise<TicketAssignmentSummary> => {
const response = await axiosClient.post<TicketAssignmentSummary>(
API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
payload,
);
return response.data;
},
getTicketAssignments: async (
ticketId: string,
): Promise<TicketAssignmentsResponse> => {
const response = await axiosClient.get<TicketAssignmentsApiResponse>(
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,

View File

@@ -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 {

View File

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