3 Commits

20 changed files with 568 additions and 234 deletions

View File

@@ -13,11 +13,11 @@ const ModulesLayout = ({
<AuthGuard> <AuthGuard>
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<main className="flex flex-1 h-screen w-full flex-col overflow-hidden"> <main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden">
<div className="scroll-stable flex-1 overflow-auto"> <div className="scroll-stable min-w-0 flex-1 overflow-auto">
<div className="mx-auto flex min-h-full w-full max-w-380 flex-col"> <div className="mx-auto flex min-h-full w-full min-w-0 max-w-380 flex-col">
<ModuleShellHeader /> <ModuleShellHeader />
<div className="flex-1 p-6">{children}</div> <div className="min-w-0 flex-1 p-6">{children}</div>
</div> </div>
</div> </div>
</main> </main>

View File

@@ -0,0 +1,267 @@
'use client';
import { useEffect } from 'react';
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 { cn } from '@/lib/utils';
import type { TicketActor, TicketAssignmentSummary } from '@/types';
import { formatDate } from '@/utils/date';
import { useTicketAssignmentsQuery } from '../../hooks/useTicketQueries';
interface TicketAssignmentsCardProps {
ticketId: string;
initialAssignments?: TicketAssignmentSummary[];
selectedAssignmentId?: number;
onAssignmentChange: (assignmentId: number) => void;
}
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="min-w-0 space-y-2">
<div className="flex min-w-0 flex-col gap-1 text-xs sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<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="flex min-w-0 flex-wrap gap-x-4 gap-y-1 text-xs">
<span className="whitespace-nowrap text-emerald-600">
{assignment.approved_detections} approved
</span>
<span className="whitespace-nowrap text-amber-600">
{assignment.pending_detections} pending
</span>
<span className="whitespace-nowrap 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="min-w-0 space-y-4 rounded-lg border p-3 sm: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 min-w-0 max-w-full 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 min-w-0 max-w-full 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,
selectedAssignmentId,
onAssignmentChange,
}: TicketAssignmentsCardProps) {
const assignmentsQuery = useTicketAssignmentsQuery(
ticketId,
initialAssignments,
);
const assignments = assignmentsQuery.data?.items ?? [];
const selectedAssignment =
assignments.find((assignment) => assignment.id === selectedAssignmentId) ??
assignments[0];
const activeAssignmentId = selectedAssignment
? String(selectedAssignment.id)
: '';
const resolvedAssignmentId = selectedAssignment?.id;
useEffect(() => {
if (
resolvedAssignmentId !== undefined &&
resolvedAssignmentId !== selectedAssignmentId
) {
onAssignmentChange(resolvedAssignmentId);
}
}, [onAssignmentChange, resolvedAssignmentId, selectedAssignmentId]);
return (
<Card className="min-w-0 max-w-full">
<CardHeader className="min-w-0 px-3 sm:px-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<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="min-w-0 space-y-3 overflow-hidden px-3 sm:px-6">
{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>
) : selectedAssignment ? (
<div className="min-w-0 space-y-3">
<nav
aria-label="Assignment lots"
className="w-full max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain touch-pan-x"
>
<div
role="tablist"
aria-orientation="horizontal"
className="flex w-max min-w-full border-b"
>
{assignments.map((assignment) => {
const assignmentId = String(assignment.id);
const isActive = assignmentId === activeAssignmentId;
return (
<button
key={assignment.id}
id={`assignment-tab-${assignmentId}`}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={`assignment-panel-${assignmentId}`}
tabIndex={isActive ? 0 : -1}
className={cn(
'relative shrink-0 px-3 py-2.5 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
isActive
? 'text-foreground after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
onClick={() => onAssignmentChange(assignment.id)}
>
Lot {assignment.id}
</button>
);
})}
</div>
</nav>
<div
id={`assignment-panel-${activeAssignmentId}`}
role="tabpanel"
aria-labelledby={`assignment-tab-${activeAssignmentId}`}
className="min-w-0"
>
<AssignmentLot assignment={selectedAssignment} />
</div>
</div>
) : (
<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

@@ -13,15 +13,14 @@ import { cn } from '@/lib/utils';
import { import {
useDiscardDetectionMutation, useDiscardDetectionMutation,
useTicketClassDetectionsQuery, useTicketDetectionsQuery,
} from '../../hooks/useTicketQueries'; } from '../../hooks/useTicketQueries';
import { DetectionDiscardDialog } from './DetectionDiscardDialog'; import { DetectionDiscardDialog } from './DetectionDiscardDialog';
interface TicketClassDetectionPreviewProps { interface TicketClassDetectionPreviewProps {
ticketId: string; ticketId: string;
videoId?: string | null; videoId?: string | null;
defectClassName: string; assignmentId?: number;
displayName: string;
totalCount?: number; totalCount?: number;
} }
@@ -96,41 +95,45 @@ function TicketClassDetectionPreviewSkeleton() {
export function TicketClassDetectionPreview({ export function TicketClassDetectionPreview({
ticketId, ticketId,
videoId, videoId,
defectClassName, assignmentId,
displayName,
totalCount, totalCount,
}: TicketClassDetectionPreviewProps) { }: TicketClassDetectionPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false); const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const visual = getDefectVisual(defectClassName);
const IssueIcon = visual.icon;
useEffect(() => { useEffect(() => {
setCurrentIndex(0); setCurrentIndex(0);
}, [defectClassName]); }, [assignmentId, videoId]);
const queryParams = useMemo( const queryParams = useMemo(
() => ({ () => ({
class_name: defectClassName,
skip: currentIndex, skip: currentIndex,
limit: 1, limit: 1,
assignment_id: assignmentId,
sort: 'timestamp_asc', sort: 'timestamp_asc',
}), }),
[currentIndex, defectClassName], [assignmentId, currentIndex],
); );
const detectionsQuery = useTicketClassDetectionsQuery( const detectionsQuery = useTicketDetectionsQuery(
videoId ?? undefined, videoId ?? undefined,
queryParams, queryParams,
); );
const detectionResult = detectionsQuery.data;
const activeDetection = detectionResult?.items[0];
const detectionCount =
detectionResult?.total ?? (assignmentId ? 0 : (totalCount ?? 0));
const activeVisual = getDefectVisual(
activeDetection?.detection.class_name ?? '',
);
const ActiveIssueIcon = activeVisual.icon;
const activeDisplayName =
activeDetection?.detection.display_name ?? 'Detection';
const discardMutation = useDiscardDetectionMutation( const discardMutation = useDiscardDetectionMutation(
ticketId, ticketId,
videoId ?? undefined, videoId ?? undefined,
defectClassName, activeDetection?.detection.class_name,
); );
const detectionResult = detectionsQuery.data;
const activeDetection = detectionResult?.items[0];
const detectionCount = detectionResult?.total ?? totalCount ?? 0;
useEffect(() => { useEffect(() => {
if (detectionCount === 0 && currentIndex !== 0) { if (detectionCount === 0 && currentIndex !== 0) {
@@ -153,27 +156,22 @@ export function TicketClassDetectionPreview({
<span <span
className={cn( className={cn(
'flex size-10 shrink-0 self-start items-center justify-center rounded-lg', 'flex size-10 shrink-0 self-start items-center justify-center rounded-lg',
visual.cardClassName, activeVisual.cardClassName,
)} )}
> >
<IssueIcon className={cn('size-5', visual.colorClassName)} /> <ActiveIssueIcon
className={cn('size-5', activeVisual.colorClassName)}
/>
</span> </span>
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<h3>{displayName}</h3> <h3>{activeDisplayName}</h3>
<Badge <Badge variant="secondary" className="rounded-lg">
variant="secondary"
className={cn(
'rounded-lg',
visual.cardClassName,
visual.colorClassName,
)}
>
{detectionCount} Detections {detectionCount} Detections
</Badge> </Badge>
</div> </div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Review detections in ascending timestamp order. Review all detections in ascending timestamp order.
</p> </p>
</div> </div>
</div> </div>
@@ -258,7 +256,7 @@ export function TicketClassDetectionPreview({
className="min-w-0" className="min-w-0"
latitude={activeDetection.location.latitude} latitude={activeDetection.location.latitude}
longitude={activeDetection.location.longitude} longitude={activeDetection.location.longitude}
label={`${displayName} - Frame ${activeDetection.frame.number}`} label={`${activeDetection.detection.display_name} - Frame ${activeDetection.frame.number}`}
title="Location on Map" title="Location on Map"
variant="plain" variant="plain"
aspectRatio="16 / 9" aspectRatio="16 / 9"

View File

@@ -1,14 +1,9 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useEffect } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Component, ListChecks } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DEFECT_CLASS_STATUS_CONFIG } from '@/constants/defectClassStatus';
import { cn } from '@/lib/utils';
import type { TicketOverviewDetail } from '@/types'; import type { TicketOverviewDetail } from '@/types';
import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries'; import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
@@ -17,148 +12,59 @@ import { TicketClassDetectionPreview } from './TicketClassDetectionPreview';
interface TicketDefectClassTabsProps { interface TicketDefectClassTabsProps {
ticketId: string; ticketId: string;
ticket?: TicketOverviewDetail; ticket?: TicketOverviewDetail;
assignmentId?: number;
} }
export function TicketDefectClassTabs({ export function TicketDefectClassTabs({
ticketId, ticketId,
ticket, ticket,
assignmentId,
}: TicketDefectClassTabsProps) { }: TicketDefectClassTabsProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const defectClassParam = searchParams.get('defect_class'); const defectClassParam = searchParams.get('defect_class');
const defectClassesQuery = useTicketDefectClassesQuery(ticketId); const defectClassesQuery = useTicketDefectClassesQuery(ticketId);
const defectClasses = useMemo(
() => defectClassesQuery.data?.defect_classes ?? [],
[defectClassesQuery.data?.defect_classes],
);
const [selectedClass, setSelectedClass] = useState<string>();
useEffect(() => { useEffect(() => {
const defectClasses = defectClassesQuery.data?.defect_classes ?? [];
if (!defectClasses.length) return; if (!defectClasses.length) return;
const hasUrlClass = defectClasses.some( const hasUrlClass = defectClasses.some(
(item) => item.class_name === defectClassParam, (item) => item.class_name === defectClassParam,
); );
if (hasUrlClass) { if (hasUrlClass) return;
setSelectedClass(defectClassParam ?? undefined);
return;
}
const firstClass = defectClasses[0].class_name; const firstClass = defectClasses[0].class_name;
const nextParams = new URLSearchParams(searchParams.toString()); const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('defect_class', firstClass); nextParams.set('defect_class', firstClass);
setSelectedClass(firstClass);
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false }); router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
}, [defectClassParam, defectClasses, pathname, router, searchParams]); }, [
defectClassParam,
defectClassesQuery.data?.defect_classes,
pathname,
router,
searchParams,
]);
const handleClassChange = (className: string) => {
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('defect_class', className);
setSelectedClass(className);
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
};
const selectedIssue = ticket?.ai_result.detections_by_class.find(
(item) => item.class_name === selectedClass,
);
const selectedClassItem = defectClasses.find(
(item) => item.class_name === selectedClass,
);
const previewDisplayName =
selectedIssue?.display_name ??
selectedClassItem?.display_name ??
selectedClass ??
'Detection';
const previewVideoId = ticket?.video_id ?? defectClassesQuery.data?.video_id; const previewVideoId = ticket?.video_id ?? defectClassesQuery.data?.video_id;
if (defectClassesQuery.isLoading) { if (!previewVideoId) return null;
return (
<Card className="w-full rounded-lg">
<CardContent className="space-y-4 p-5">
<div className="h-5 w-24 animate-pulse rounded-lg bg-muted" />
<div className="flex flex-col gap-2">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="h-11 w-full animate-pulse rounded-lg bg-muted"
/>
))}
</div>
</CardContent>
</Card>
);
}
if (defectClassesQuery.isError || !defectClasses.length || !selectedClass) {
return null;
}
return ( return (
<Tabs
value={selectedClass}
onValueChange={handleClassChange}
orientation="vertical"
className="w-full"
>
<Card className="w-full"> <Card className="w-full">
<CardContent className="grid gap-0 p-0 lg:grid-cols-[minmax(220px,280px)_1fr] lg:divide-x lg:divide-border"> <CardContent className="p-5">
<div className="space-y-4 px-5"> <div className="min-w-0">
<div className="flex items-center gap-2">
<ListChecks className="size-5 shrink-0 text-primary" />
<h2 className="text-base font-semibold">Detected Issues</h2>
<Badge
variant="secondary"
className="min-w-5 min-h-5 justify-center rounded-full"
>
{defectClasses.length}
</Badge>
</div>
<TabsList className="flex h-auto w-full flex-col items-stretch gap-2 bg-transparent p-0">
{defectClasses.map((item) => {
const statusConfig =
DEFECT_CLASS_STATUS_CONFIG[item.assignment_status];
return (
<TabsTrigger
key={item.class_name}
value={item.class_name}
className={cn(
'group h-auto w-full flex-none items-center justify-between! gap-2 rounded-lg border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground shadow-none after:hidden',
'data-[state=active]:bg-muted data-[state=active]:text-foreground',
)}
>
<span className="min-w-0 truncate text-left font-medium">
{item.display_name}
</span>
<Badge
className={cn(
'shrink-0 rounded-full',
statusConfig.badgeClassName,
)}
>
<Component className="size-3" />
{statusConfig.label}
</Badge>
</TabsTrigger>
);
})}
</TabsList>
</div>
<div className="min-w-0 px-5">
<TicketClassDetectionPreview <TicketClassDetectionPreview
ticketId={ticketId} ticketId={ticketId}
videoId={previewVideoId} videoId={previewVideoId}
defectClassName={selectedClass} assignmentId={assignmentId}
displayName={previewDisplayName} totalCount={ticket?.ai_result.detection_count}
totalCount={selectedIssue?.count}
/> />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</Tabs>
); );
} }

View File

@@ -6,14 +6,17 @@ import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
import type { TicketOverviewDetail } from '@/types'; import type { TicketDetail, TicketOverviewDetail } from '@/types';
import { AssignTicketAction } from './actions/AssignTicketAction'; import { AssignTicketAction } from './actions/AssignTicketAction';
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
export function TicketDetailHeader({ export function TicketDetailHeader({
ticket, ticket,
classDetail,
}: { }: {
ticket: TicketOverviewDetail; ticket: TicketOverviewDetail;
classDetail?: TicketDetail;
}) { }) {
const router = useRouter(); const router = useRouter();
const ticketLabel = ticket.ticket_name || ticket.id; const ticketLabel = ticket.ticket_name || ticket.id;
@@ -26,6 +29,11 @@ export function TicketDetailHeader({
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{classDetail?.assignment_status === 'under_review' ? (
<PermissionGuard permissions={PERMISSIONS.TICKET.REVIEW}>
<OpenRepairReviewAction ticket={classDetail} />
</PermissionGuard>
) : null}
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}> <PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<AssignTicketAction ticketId={ticket.id} /> <AssignTicketAction ticketId={ticket.id} />
</PermissionGuard> </PermissionGuard>

View File

@@ -2,25 +2,24 @@
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
import type { TicketDetail } from '@/types'; import type { TicketAssignmentSummary, TicketDetail } from '@/types';
import { AssignedContractorAction } from './actions/AssignedContractorAction'; import { AssignedContractorAction } from './actions/AssignedContractorAction';
import { ClosedTicketAction } from './actions/ClosedTicketAction'; import { ClosedTicketAction } from './actions/ClosedTicketAction';
import { NoTicketAction } from './actions/NoTicketAction'; import { NoTicketAction } from './actions/NoTicketAction';
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
import { RequestExtensionAction } from './actions/RequestExtensionAction'; import { RequestExtensionAction } from './actions/RequestExtensionAction';
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction'; import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
interface TicketStatusActionsProps { interface TicketStatusActionsProps {
ticket: TicketDetail; ticket: TicketDetail;
assignment?: TicketAssignmentSummary;
} }
function getTicketAction(ticket: TicketDetail) { function getTicketAction(
if (ticket.assignment_status === 'unassigned') { ticket: TicketDetail,
return null; assignment?: TicketAssignmentSummary,
} ) {
if (assignment && !assignment.is_complete) {
if (ticket.assignment_status === 'assigned') {
return ( return (
<PermissionGuard <PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN} permissions={PERMISSIONS.TICKET.ASSIGN}
@@ -29,29 +28,46 @@ function getTicketAction(ticket: TicketDetail) {
permissions={PERMISSIONS.TICKET.WORK} permissions={PERMISSIONS.TICKET.WORK}
fallback={<NoTicketAction />} fallback={<NoTicketAction />}
> >
<RequestExtensionAction ticket={ticket} /> <RequestExtensionAction
ticket={ticket}
assignmentId={assignment.id}
dueAt={assignment.due_at}
/>
</PermissionGuard> </PermissionGuard>
} }
> >
<> <>
{ticket.assignment_status === 'assigned' ? (
<AssignedContractorAction ticket={ticket} /> <AssignedContractorAction ticket={ticket} />
<ReviewExtensionRequestAction ticket={ticket} /> ) : null}
<ReviewExtensionRequestAction
ticket={ticket}
assignmentId={assignment.id}
/>
</> </>
</PermissionGuard> </PermissionGuard>
); );
} }
if (ticket.assignment_status === 'under_review') { if (ticket.assignment_status === 'unassigned') {
return null;
}
if (ticket.assignment_status === 'assigned') {
return ( return (
<PermissionGuard <PermissionGuard
permissions={PERMISSIONS.TICKET.REVIEW} permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={<NoTicketAction />} fallback={<NoTicketAction />}
> >
<OpenRepairReviewAction ticket={ticket} /> <AssignedContractorAction ticket={ticket} />
</PermissionGuard> </PermissionGuard>
); );
} }
if (ticket.assignment_status === 'under_review') {
return null;
}
if ( if (
ticket.assignment_status === 'approved' || ticket.assignment_status === 'approved' ||
ticket.assignment_status === 'rejected' ticket.assignment_status === 'rejected'
@@ -62,8 +78,11 @@ function getTicketAction(ticket: TicketDetail) {
return <NoTicketAction />; return <NoTicketAction />;
} }
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) { export function TicketStatusActions({
const action = getTicketAction(ticket); ticket,
assignment,
}: TicketStatusActionsProps) {
const action = getTicketAction(ticket, assignment);
return action ? <div className="space-y-3">{action}</div> : null; return action ? <div className="space-y-3">{action}</div> : null;
} }

View File

@@ -1,37 +1,24 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { ArrowRight, ClipboardCheck } from 'lucide-react'; import { ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { TicketDetail } from '@/types'; import type { TicketDetail } from '@/types';
import { ROUTES } from '@/utils/routes'; import { ROUTES } from '@/utils/routes';
import { TicketActionCard } from './TicketActionCard';
export function OpenRepairReviewAction({ ticket }: { ticket: TicketDetail }) { export function OpenRepairReviewAction({ ticket }: { ticket: TicketDetail }) {
const router = useRouter(); const router = useRouter();
return ( return (
<TicketActionCard icon={ClipboardCheck} title="Review Submitted Repairs">
<div className="space-y-4">
<p className="text-sm leading-relaxed text-muted-foreground">
Review every submitted repair separately using its detection image,
location, and repair proof.
</p>
<Button <Button
type="button" type="button"
className="w-full"
onClick={() => onClick={() =>
router.push( router.push(ROUTES.TICKET_CLASS_REVIEW(ticket.id, ticket.defect_class))
ROUTES.TICKET_CLASS_REVIEW(ticket.id, ticket.defect_class),
)
} }
> >
Open Issue Review <ClipboardCheck />
<ArrowRight /> Review Repairs
</Button> </Button>
</div>
</TicketActionCard>
); );
} }

View File

@@ -5,10 +5,22 @@ import type { TicketDetail } from '@/types';
import { TicketActionCard } from './TicketActionCard'; import { TicketActionCard } from './TicketActionCard';
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel'; import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
export function RequestExtensionAction({ ticket }: { ticket: TicketDetail }) { export function RequestExtensionAction({
ticket,
assignmentId,
dueAt,
}: {
ticket: TicketDetail;
assignmentId: number;
dueAt: string | null;
}) {
return ( return (
<TicketActionCard icon={Clock3} title="Request Extension"> <TicketActionCard icon={Clock3} title="Request Extension">
<TicketExtensionRequestPanel ticket={ticket} /> <TicketExtensionRequestPanel
ticket={ticket}
assignmentId={assignmentId}
dueAt={dueAt}
/>
</TicketActionCard> </TicketActionCard>
); );
} }

View File

@@ -16,10 +16,15 @@ const MAX_REASON_LENGTH = 300;
interface RequestExtensionFormProps { interface RequestExtensionFormProps {
ticket: TicketDetail; ticket: TicketDetail;
assignmentId: number;
currentDueAt: string | null;
} }
export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) { export function RequestExtensionForm({
const currentDueAt = ticket.worker?.due_at; ticket,
assignmentId,
currentDueAt,
}: RequestExtensionFormProps) {
const currentDueDate = useMemo( const currentDueDate = useMemo(
() => (currentDueAt ? new Date(currentDueAt) : undefined), () => (currentDueAt ? new Date(currentDueAt) : undefined),
[currentDueAt], [currentDueAt],
@@ -36,7 +41,6 @@ export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) {
); );
const [requestedDueDate, setRequestedDueDate] = useState<Date>(); const [requestedDueDate, setRequestedDueDate] = useState<Date>();
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
const assignmentId = ticket.worker?.assignment_id;
const requestExtensionMutation = useRequestTicketExtensionMutation( const requestExtensionMutation = useRequestTicketExtensionMutation(
ticket.id, ticket.id,
assignmentId, assignmentId,
@@ -48,10 +52,7 @@ export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) {
(!currentDueDate || requestedDueDate.getTime() > currentDueDate.getTime()), (!currentDueDate || requestedDueDate.getTime() > currentDueDate.getTime()),
); );
const canSubmit = const canSubmit =
Boolean(assignmentId) && isRequestedDateValid && Boolean(reason.trim()) && !isPending;
isRequestedDateValid &&
Boolean(reason.trim()) &&
!isPending;
const handleSubmit = async () => { const handleSubmit = async () => {
if (!requestedDueDate || !canSubmit) return; if (!requestedDueDate || !canSubmit) return;

View File

@@ -77,16 +77,17 @@ function Requester({ request }: { request: TicketExtensionRequest }) {
function ExtensionReviewForm({ function ExtensionReviewForm({
ticket, ticket,
assignmentId,
request, request,
}: { }: {
ticket: TicketDetail; ticket: TicketDetail;
assignmentId: number;
request: TicketExtensionRequest; request: TicketExtensionRequest;
}) { }) {
const [note, setNote] = useState(''); const [note, setNote] = useState('');
const [selectedAction, setSelectedAction] = useState< const [selectedAction, setSelectedAction] = useState<
'approve' | 'reject' | null 'approve' | 'reject' | null
>(null); >(null);
const assignmentId = ticket.worker?.assignment_id as number;
const reviewMutation = useReviewTicketExtensionMutation( const reviewMutation = useReviewTicketExtensionMutation(
ticket.id, ticket.id,
assignmentId, assignmentId,
@@ -197,16 +198,17 @@ function ExtensionReviewForm({
export function ReviewExtensionRequestAction({ export function ReviewExtensionRequestAction({
ticket, ticket,
assignmentId,
}: { }: {
ticket: TicketDetail; ticket: TicketDetail;
assignmentId: number;
}) { }) {
const assignmentId = ticket.worker?.assignment_id;
const extensionRequestQuery = useTicketExtensionRequestQuery( const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id, ticket.id,
assignmentId, assignmentId,
); );
if (!assignmentId || extensionRequestQuery.isError) { if (extensionRequestQuery.isError) {
return null; return null;
} }
@@ -227,5 +229,11 @@ export function ReviewExtensionRequestAction({
if (!pendingRequest) return null; if (!pendingRequest) return null;
return <ExtensionReviewForm ticket={ticket} request={pendingRequest} />; return (
<ExtensionReviewForm
ticket={ticket}
assignmentId={assignmentId}
request={pendingRequest}
/>
);
} }

View File

@@ -187,23 +187,18 @@ function ExtensionRequestDetails({
export function TicketExtensionRequestPanel({ export function TicketExtensionRequestPanel({
ticket, ticket,
assignmentId,
dueAt,
}: { }: {
ticket: TicketDetail; ticket: TicketDetail;
assignmentId: number;
dueAt: string | null;
}) { }) {
const assignmentId = ticket.worker?.assignment_id;
const extensionRequestQuery = useTicketExtensionRequestQuery( const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id, ticket.id,
assignmentId, assignmentId,
); );
if (!assignmentId) {
return (
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
No active assignment is available for this ticket.
</div>
);
}
if (extensionRequestQuery.isLoading) { if (extensionRequestQuery.isLoading) {
return ( return (
<div className="flex min-h-40 items-center justify-center text-muted-foreground"> <div className="flex min-h-40 items-center justify-center text-muted-foreground">
@@ -237,7 +232,13 @@ export function TicketExtensionRequestPanel({
: null; : null;
if (!latestRequest) { if (!latestRequest) {
return <RequestExtensionForm ticket={ticket} />; return (
<RequestExtensionForm
ticket={ticket}
assignmentId={assignmentId}
currentDueAt={dueAt}
/>
);
} }
return <ExtensionRequestDetails request={latestRequest} />; return <ExtensionRequestDetails request={latestRequest} />;

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useState } from 'react';
import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
@@ -16,11 +17,13 @@ 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';
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents'; import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
import { import {
useTicketAssignmentsQuery,
useTicketClassDetailQuery, useTicketClassDetailQuery,
useTicketOverviewQuery, useTicketOverviewQuery,
} from '../hooks/useTicketQueries'; } from '../hooks/useTicketQueries';
@@ -30,11 +33,21 @@ export default function TicketDetailPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { ticketId } = useParams() as { ticketId: string }; const { ticketId } = useParams() as { ticketId: string };
const defectClass = searchParams.get('defect_class') ?? undefined; const defectClass = searchParams.get('defect_class') ?? undefined;
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>();
const overviewQuery = useTicketOverviewQuery(ticketId); const overviewQuery = useTicketOverviewQuery(ticketId);
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
const overview = overviewQuery.data; const overview = overviewQuery.data;
const assignmentsQuery = useTicketAssignmentsQuery(
ticketId,
overview?.assignments,
);
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
const classDetail = classDetailQuery.data; const classDetail = classDetailQuery.data;
const assignments = assignmentsQuery.data?.items ?? [];
const activeAssignmentId = selectedAssignmentId ?? assignments[0]?.id;
const activeAssignment = assignments.find(
(assignment) => assignment.id === activeAssignmentId,
);
const isClassDetailLoading = Boolean( const isClassDetailLoading = Boolean(
defectClass && classDetailQuery.isLoading, defectClass && classDetailQuery.isLoading,
); );
@@ -58,33 +71,48 @@ export default function TicketDetailPage() {
} }
return ( return (
<main className="relative z-10 space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden"> <main className="relative z-10 min-w-0 max-w-full space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
<div className="xl:shrink-0"> <div className="xl:shrink-0">
{overview ? ( {overview ? (
<TicketDetailHeader ticket={overview} /> <TicketDetailHeader ticket={overview} classDetail={classDetail} />
) : ( ) : (
<TicketOverviewHeaderSkeleton /> <TicketOverviewHeaderSkeleton />
)} )}
</div> </div>
<div className="grid items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)] xl:items-stretch xl:overflow-hidden"> <div className="grid min-w-0 items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)] xl:items-stretch xl:overflow-hidden">
<div className="space-y-5 xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2"> <div className="min-w-0 space-y-5 xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
{overview ? ( {overview ? (
<TicketOverviewCard ticket={overview} /> <TicketOverviewCard ticket={overview} />
) : ( ) : (
<TicketOverviewCardSkeleton /> <TicketOverviewCardSkeleton />
)} )}
<TicketDefectClassTabs ticketId={ticketId} ticket={overview} /> {overview ? (
<TicketAssignmentsCard
ticketId={ticketId}
initialAssignments={overview.assignments}
selectedAssignmentId={activeAssignmentId}
onAssignmentChange={setSelectedAssignmentId}
/>
) : null}
<TicketDefectClassTabs
ticketId={ticketId}
ticket={overview}
assignmentId={activeAssignmentId}
/>
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null} {isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
{!isClassDetailLoading && classDetail ? ( {!isClassDetailLoading && classDetail ? (
<TicketHistoryCard ticket={classDetail} /> <TicketHistoryCard ticket={classDetail} />
) : null} ) : null}
</div> </div>
<div className="space-y-3 self-start xl:min-h-0 xl:self-stretch xl:overflow-y-auto xl:overscroll-contain xl:pr-2"> <div className="min-w-0 space-y-3 self-start xl:min-h-0 xl:self-stretch xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null} {isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
{!isClassDetailLoading && classDetail ? ( {!isClassDetailLoading && classDetail ? (
<TicketStatusActions ticket={classDetail} /> <TicketStatusActions
ticket={classDetail}
assignment={activeAssignment}
/>
) : null} ) : null}
</div> </div>
</div> </div>

View File

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

View File

@@ -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,
@@ -100,12 +120,14 @@ export function useTicketDetectionsQuery(
() => ({ () => ({
skip: params.skip ?? 0, skip: params.skip ?? 0,
limit: params.limit ?? 1, limit: params.limit ?? 1,
assignment_id: params.assignment_id,
class_name: params.class_name, class_name: params.class_name,
min_confidence: params.min_confidence, min_confidence: params.min_confidence,
sort: params.sort ?? 'timestamp_asc', sort: params.sort ?? 'timestamp_asc',
search: params.search, search: params.search,
}), }),
[ [
params.assignment_id,
params.class_name, params.class_name,
params.limit, params.limit,
params.min_confidence, params.min_confidence,
@@ -200,7 +222,7 @@ export function useTicketClassReviewDetectionsQuery(
export function useDiscardDetectionMutation( export function useDiscardDetectionMutation(
ticketId: string, ticketId: string,
videoId: string | undefined, videoId: string | undefined,
defectClass: string, defectClass?: string,
) { ) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -224,13 +246,20 @@ export function useDiscardDetectionMutation(
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId), queryKey: ticketKeys.overview(ticketId),
}), }),
...(defectClass
? [
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass), queryKey: ticketKeys.classDetail(ticketId, defectClass),
}), }),
]
: []),
queryClient.invalidateQueries({ queryClient.invalidateQueries({
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'),
@@ -376,6 +405,9 @@ export function useRequestTicketExtensionMutation(
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass), queryKey: ticketKeys.classDetail(ticketId, defectClass),
}); });
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
}, },
onError: () => toast.error('Failed to request an extension'), onError: () => toast.error('Failed to request an extension'),
@@ -403,6 +435,9 @@ export function useReviewTicketExtensionMutation(
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass), queryKey: ticketKeys.classDetail(ticketId, defectClass),
}); });
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
}, },
onError: () => toast.error('Failed to review extension request'), onError: () => toast.error('Failed to review extension request'),

View File

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

View File

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

View File

@@ -73,6 +73,7 @@ export const videoService = {
params: { params: {
skip: params?.skip ?? 0, skip: params?.skip ?? 0,
limit: params?.limit ?? 1, limit: params?.limit ?? 1,
assignment_id: params?.assignment_id,
class_name: params?.class_name, class_name: params?.class_name,
proof_status: params?.proof_status, proof_status: params?.proof_status,
min_confidence: params?.min_confidence, min_confidence: params?.min_confidence,

View File

@@ -110,6 +110,7 @@ export type DetectionResultLog = DetectionResultItem & {
export type VideoDetectionsParams = { export type VideoDetectionsParams = {
skip?: number; skip?: number;
limit?: number; limit?: number;
assignment_id?: number;
class_name?: string | string[]; class_name?: string | string[];
proof_status?: DetectionProofStatus; proof_status?: DetectionProofStatus;
min_confidence?: number; min_confidence?: number;

View File

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

View File

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