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';
|
} from './components/TicketClassDetailSkeleton';
|
||||||
|
|
||||||
import { TicketHistoryCard } from './components/TicketHistoryCard';
|
import { TicketHistoryCard } from './components/TicketHistoryCard';
|
||||||
|
import { TicketAssignmentsCard } from './components/TicketAssignmentsCard';
|
||||||
import { TicketOverviewCard } from './components/TicketOverviewCard';
|
import { TicketOverviewCard } from './components/TicketOverviewCard';
|
||||||
import { TicketStatusActions } from './components/TicketStatusActions';
|
import { TicketStatusActions } from './components/TicketStatusActions';
|
||||||
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
|
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
|
||||||
@@ -74,6 +75,12 @@ export default function TicketDetailPage() {
|
|||||||
) : (
|
) : (
|
||||||
<TicketOverviewCardSkeleton />
|
<TicketOverviewCardSkeleton />
|
||||||
)}
|
)}
|
||||||
|
{overview ? (
|
||||||
|
<TicketAssignmentsCard
|
||||||
|
ticketId={ticketId}
|
||||||
|
initialAssignments={overview.assignments}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<TicketDefectClassTabs ticketId={ticketId} ticket={overview} />
|
<TicketDefectClassTabs ticketId={ticketId} ticket={overview} />
|
||||||
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
|
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
|
||||||
{!isClassDetailLoading && classDetail ? (
|
{!isClassDetailLoading && classDetail ? (
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export function useTicketDetailEvents(
|
|||||||
if (!ticketId) return;
|
if (!ticketId) return;
|
||||||
|
|
||||||
queryClient.refetchQueries({ queryKey: ticketKeys.overview(ticketId) });
|
queryClient.refetchQueries({ queryKey: ticketKeys.overview(ticketId) });
|
||||||
|
queryClient.refetchQueries({
|
||||||
|
queryKey: ticketKeys.assignments(ticketId),
|
||||||
|
});
|
||||||
if (defectClassRef.current) {
|
if (defectClassRef.current) {
|
||||||
queryClient.refetchQueries({
|
queryClient.refetchQueries({
|
||||||
queryKey: ticketKeys.classDetail(ticketId, defectClassRef.current),
|
queryKey: ticketKeys.classDetail(ticketId, defectClassRef.current),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
ReviewDetectionRepairProofPayload,
|
ReviewDetectionRepairProofPayload,
|
||||||
ReviewTicketExtensionPayload,
|
ReviewTicketExtensionPayload,
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
|
TicketAssignmentSummary,
|
||||||
TicketListParams,
|
TicketListParams,
|
||||||
DetectionProofStatus,
|
DetectionProofStatus,
|
||||||
VideoDetectionsParams,
|
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(
|
export function useTicketDetectionsQuery(
|
||||||
videoId: string | undefined,
|
videoId: string | undefined,
|
||||||
params: VideoDetectionsParams,
|
params: VideoDetectionsParams,
|
||||||
@@ -231,6 +251,9 @@ export function useDiscardDetectionMutation(
|
|||||||
queryKey: ticketKeys.defectClasses(ticketId),
|
queryKey: ticketKeys.defectClasses(ticketId),
|
||||||
}),
|
}),
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.assignments(ticketId),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
onError: () => toast.error('Failed to discard detection'),
|
onError: () => toast.error('Failed to discard detection'),
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export const ticketKeys = {
|
|||||||
] as const,
|
] as const,
|
||||||
defectClasses: (ticketId: string) =>
|
defectClasses: (ticketId: string) =>
|
||||||
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
|
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
|
||||||
|
assignments: (ticketId: string) =>
|
||||||
|
[...ticketKeys.details(), ticketId, 'assignments'] as const,
|
||||||
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
|
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
|
||||||
[
|
[
|
||||||
...ticketKeys.details(),
|
...ticketKeys.details(),
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
ReviewTicketExtensionPayload,
|
ReviewTicketExtensionPayload,
|
||||||
SseTokenResponse,
|
SseTokenResponse,
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
|
TicketAssignmentSummary,
|
||||||
|
TicketAssignmentsResponse,
|
||||||
TicketExtensionRequestsResponse,
|
TicketExtensionRequestsResponse,
|
||||||
TicketOverviewDetail,
|
TicketOverviewDetail,
|
||||||
TicketDefectClassesResponse,
|
TicketDefectClassesResponse,
|
||||||
@@ -16,6 +18,14 @@ import type {
|
|||||||
TicketListResponse,
|
TicketListResponse,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
|
type TicketAssignmentsApiResponse =
|
||||||
|
| TicketAssignmentSummary[]
|
||||||
|
| TicketAssignmentsResponse
|
||||||
|
| {
|
||||||
|
assignments: TicketAssignmentSummary[];
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const ticketService = {
|
export const ticketService = {
|
||||||
getTickets: async (
|
getTickets: async (
|
||||||
params?: TicketListParams,
|
params?: TicketListParams,
|
||||||
@@ -80,14 +90,36 @@ export const ticketService = {
|
|||||||
assignTicket: async (
|
assignTicket: async (
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
payload: AssignTicketPayload,
|
payload: AssignTicketPayload,
|
||||||
): Promise<TicketDetail> => {
|
): Promise<TicketAssignmentSummary> => {
|
||||||
const response = await axiosClient.post<TicketDetail>(
|
const response = await axiosClient.post<TicketAssignmentSummary>(
|
||||||
API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
|
API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
|
||||||
payload,
|
payload,
|
||||||
);
|
);
|
||||||
return response.data;
|
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 (
|
requestTicketExtension: async (
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
assignmentId: number,
|
assignmentId: number,
|
||||||
|
|||||||
@@ -23,11 +23,12 @@ export interface AssignableTicketUsersResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketAssignmentCriteria {
|
export interface TicketAssignmentCriteria {
|
||||||
class_names?: string[];
|
class_names?: string[] | null;
|
||||||
start_lat?: number;
|
start_lat?: number | null;
|
||||||
start_lng?: number;
|
start_lng?: number | null;
|
||||||
end_lat?: number;
|
end_lat?: number | null;
|
||||||
end_lng?: number;
|
end_lng?: number | null;
|
||||||
|
min_confidence?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssignTicketPayload {
|
export interface AssignTicketPayload {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { TicketDefectClassTicketStatus } from './defect-class';
|
import type { TicketDefectClassTicketStatus } from './defect-class';
|
||||||
import type { TicketStatus } from './status';
|
import type { TicketStatus } from './status';
|
||||||
|
import type { TicketAssignmentCriteria } from './actions';
|
||||||
|
|
||||||
export interface TicketActor {
|
export interface TicketActor {
|
||||||
user_id: number | null;
|
user_id: number | null;
|
||||||
@@ -114,6 +115,28 @@ export interface TicketDetectionClassCount {
|
|||||||
count: number;
|
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 {
|
export interface TicketOverviewDetail {
|
||||||
id: string;
|
id: string;
|
||||||
ticket_name?: string | null;
|
ticket_name?: string | null;
|
||||||
@@ -134,6 +157,7 @@ export interface TicketOverviewDetail {
|
|||||||
available_defect_classes: string[];
|
available_defect_classes: string[];
|
||||||
detections_by_class: TicketDetectionClassCount[];
|
detections_by_class: TicketDetectionClassCount[];
|
||||||
};
|
};
|
||||||
|
assignments?: TicketAssignmentSummary[];
|
||||||
timestamps?: TicketTimestamps | null;
|
timestamps?: TicketTimestamps | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user