feat(ticket): add assignment lots card to ticket detail
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user