feat(tickets): add unassigned issue triage to ticket details

- embed detection previews within work assignments
- allow discarding only unassigned detections
- improve ticket detail labels, counts, and layout
This commit is contained in:
2026-08-07 15:31:47 +05:30
parent c8899c191e
commit 47697b4421
9 changed files with 177 additions and 74 deletions

View File

@@ -20,9 +20,11 @@ import type { TicketActor, TicketAssignmentSummary } from '@/types';
import { formatDate } from '@/utils/date'; import { formatDate } from '@/utils/date';
import { useTicketAssignmentsQuery } from '../../hooks/useTicketQueries'; import { useTicketAssignmentsQuery } from '../../hooks/useTicketQueries';
import { TicketDetectionPreview } from './TicketDetectionPreview';
interface TicketAssignmentsCardProps { interface TicketAssignmentsCardProps {
ticketId: string; ticketId: string;
videoId?: string | null;
initialAssignments?: TicketAssignmentSummary[]; initialAssignments?: TicketAssignmentSummary[];
selectedAssignmentId?: number; selectedAssignmentId?: number;
onAssignmentChange: (assignmentId: number) => void; onAssignmentChange: (assignmentId: number) => void;
@@ -87,7 +89,7 @@ function AssignmentProgress({
); );
} }
function AssignmentLot({ function AssignmentOverview({
assignment, assignment,
}: { }: {
assignment: TicketAssignmentSummary; assignment: TicketAssignmentSummary;
@@ -109,7 +111,7 @@ function AssignmentLot({
<div className="min-w-0 space-y-4 rounded-lg border p-3 sm:p-4"> <div className="min-w-0 space-y-4 rounded-lg border p-3 sm:p-4">
<div className="min-w-0"> <div className="min-w-0">
<p className="mb-2 text-xs font-medium text-muted-foreground"> <p className="mb-2 text-xs font-medium text-muted-foreground">
Lot {assignment.id} Assignment {assignment.id}
</p> </p>
<PersonInfo person={worker} /> <PersonInfo person={worker} />
</div> </div>
@@ -138,6 +140,7 @@ function AssignmentLot({
export function TicketAssignmentsCard({ export function TicketAssignmentsCard({
ticketId, ticketId,
videoId,
initialAssignments, initialAssignments,
selectedAssignmentId, selectedAssignmentId,
onAssignmentChange, onAssignmentChange,
@@ -173,14 +176,17 @@ export function TicketAssignmentsCard({
<div className="min-w-0"> <div className="min-w-0">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<BriefcaseBusiness className="size-5 text-primary" /> <BriefcaseBusiness className="size-5 text-primary" />
Assignment lots Work Assignments
</CardTitle> </CardTitle>
<CardDescription className="mt-1"> <CardDescription className="mt-1">
Live ownership, criteria, and repair progress for this ticket. Issues grouped by assignee, scope, and repair progress.
</CardDescription> </CardDescription>
</div> </div>
{assignmentsQuery.data ? ( {assignmentsQuery.data ? (
<Badge variant="default">{assignments.length}</Badge> <Badge variant="default">
{assignments.length}{' '}
{assignments.length === 1 ? 'Assignment' : 'Assignments'}
</Badge>
) : null} ) : null}
</div> </div>
</CardHeader> </CardHeader>
@@ -195,7 +201,7 @@ export function TicketAssignmentsCard({
) : assignmentsQuery.isError ? ( ) : assignmentsQuery.isError ? (
<div className="rounded-lg border border-dashed p-6 text-center"> <div className="rounded-lg border border-dashed p-6 text-center">
<p className="text-sm font-medium"> <p className="text-sm font-medium">
Unable to load assignment lots. Unable to load work assignments.
</p> </p>
<Button <Button
type="button" type="button"
@@ -210,7 +216,7 @@ export function TicketAssignmentsCard({
) : selectedAssignment ? ( ) : selectedAssignment ? (
<div className="min-w-0 space-y-3"> <div className="min-w-0 space-y-3">
<nav <nav
aria-label="Assignment lots" aria-label="Work assignments"
className="w-full max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain touch-pan-x" className="w-full max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain touch-pan-x"
> >
<div <div
@@ -239,7 +245,7 @@ export function TicketAssignmentsCard({
)} )}
onClick={() => onAssignmentChange(assignment.id)} onClick={() => onAssignmentChange(assignment.id)}
> >
Lot {assignment.id} Assignment {assignment.id}
</button> </button>
); );
})} })}
@@ -252,14 +258,23 @@ export function TicketAssignmentsCard({
aria-labelledby={`assignment-tab-${activeAssignmentId}`} aria-labelledby={`assignment-tab-${activeAssignmentId}`}
className="min-w-0" className="min-w-0"
> >
<AssignmentLot assignment={selectedAssignment} /> <AssignmentOverview assignment={selectedAssignment} />
{videoId ? (
<div className={'mt-4 border-t pt-4'}>
<TicketDetectionPreview
ticketId={ticketId}
videoId={videoId}
assignmentId={selectedAssignment.id}
/>
</div>
) : null}
</div> </div>
</div> </div>
) : ( ) : (
<div className="rounded-lg border border-dashed px-4 py-8 text-center"> <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="text-sm font-medium">No work assignments yet</p>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Assignment lots will appear here after work is allocated. Work assignments will appear here after issues are allocated.
</p> </p>
</div> </div>
)} )}

View File

@@ -3,6 +3,7 @@
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Badge } from '@/components/ui/badge';
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';
@@ -17,7 +18,7 @@ export function TicketDetailHeader({
ticket: TicketOverviewDetail; ticket: TicketOverviewDetail;
}) { }) {
const router = useRouter(); const router = useRouter();
const ticketLabel = ticket.ticket_name || ticket.id; const ticketName = ticket.ticket_name || 'Unnamed ticket';
return ( return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@@ -32,11 +33,17 @@ export function TicketDetailHeader({
> >
<ArrowLeft /> <ArrowLeft />
</Button> </Button>
<div className="min-w-0 space-y-1"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
Ticket Detail Ticket Detail
</h1> </h1>
<p className="text-xs">Ticket: {ticketLabel}</p> <Badge
variant="secondary"
className="max-w-full font-mono text-xs"
title={ticketName}
>
<span className="truncate">{ticketName}</span>
</Badge>
</div> </div>
</div> </div>

View File

@@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react'; import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
import DetectionLocationMap from '@/components/map/detectionLocationMap'; import DetectionLocationMap from '@/components/map/detectionLocationMap';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage'; import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
@@ -20,7 +19,9 @@ import { DetectionDiscardDialog } from './DetectionDiscardDialog';
interface TicketDetectionPreviewProps { interface TicketDetectionPreviewProps {
ticketId: string; ticketId: string;
videoId?: string | null; videoId?: string | null;
assignmentId: number; assignmentId?: number;
assignmentStatus?: 'unassigned';
onDetectionCountChange?: (count: number | undefined) => void;
} }
function DetectionMetadataBar({ function DetectionMetadataBar({
@@ -95,22 +96,28 @@ export function TicketDetectionPreview({
ticketId, ticketId,
videoId, videoId,
assignmentId, assignmentId,
assignmentStatus,
onDetectionCountChange,
}: TicketDetectionPreviewProps) { }: TicketDetectionPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false); const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const isUnassignedPreview = assignmentStatus === 'unassigned';
useEffect(() => { useEffect(() => {
setCurrentIndex(0); setCurrentIndex(0);
}, [assignmentId, videoId]); }, [assignmentId, assignmentStatus, videoId]);
const queryParams = useMemo( const queryParams = useMemo(
() => ({ () => ({
skip: currentIndex, skip: currentIndex,
limit: 1, limit: 1,
assignment_id: assignmentId, assignment_id: isUnassignedPreview ? undefined : assignmentId,
assignment_status: isUnassignedPreview
? ('unassigned' as const)
: undefined,
sort: 'timestamp_asc', sort: 'timestamp_asc',
}), }),
[assignmentId, currentIndex], [assignmentId, currentIndex, isUnassignedPreview],
); );
const detectionsQuery = useTicketDetectionsQuery( const detectionsQuery = useTicketDetectionsQuery(
@@ -120,7 +127,7 @@ export function TicketDetectionPreview({
const detectionResult = detectionsQuery.data; const detectionResult = detectionsQuery.data;
const activeDetection = detectionResult?.items[0]; const activeDetection = detectionResult?.items[0];
const confirmedDetectionCount = detectionResult?.total; const confirmedDetectionCount = detectionResult?.total;
const detectionCount = detectionResult?.total ?? 0; const detectionCount = confirmedDetectionCount ?? 0;
const activeVisual = getDefectVisual( const activeVisual = getDefectVisual(
activeDetection?.detection.class_name ?? '', activeDetection?.detection.class_name ?? '',
); );
@@ -148,8 +155,15 @@ export function TicketDetectionPreview({
} }
}, [confirmedDetectionCount, currentIndex]); }, [confirmedDetectionCount, currentIndex]);
useEffect(() => {
onDetectionCountChange?.(confirmedDetectionCount);
}, [confirmedDetectionCount, onDetectionCountChange]);
const isNavigationDisabled = const isNavigationDisabled =
detectionCount === 0 || detectionsQuery.isLoading; detectionCount === 0 || detectionsQuery.isLoading;
const isInitialLoading = detectionsQuery.isLoading;
const isError = detectionsQuery.isError;
const retry = () => void detectionsQuery.refetch();
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -165,16 +179,8 @@ export function TicketDetectionPreview({
className={cn('size-5', activeVisual.colorClassName)} className={cn('size-5', activeVisual.colorClassName)}
/> />
</span> </span>
<div className="min-w-0 space-y-1"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3>{activeDisplayName}</h3> <h3>{activeDisplayName}</h3>
<Badge variant="secondary" className="rounded-lg">
{detectionCount} Detections
</Badge>
</div>
<p className="text-muted-foreground">
Review all detections in ascending timestamp order.
</p>
</div> </div>
</div> </div>
@@ -215,28 +221,27 @@ export function TicketDetectionPreview({
</div> </div>
</div> </div>
{detectionsQuery.isLoading && !detectionsQuery.data ? ( {isInitialLoading && !activeDetection ? (
<TicketDetectionPreviewSkeleton /> <TicketDetectionPreviewSkeleton />
) : detectionsQuery.isError ? ( ) : isError ? (
<div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-8 text-center"> <div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-8 text-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<AlertTriangle className="size-6 text-destructive" /> <AlertTriangle className="size-6 text-destructive" />
<p className="text-sm font-medium text-destructive"> <p className="text-sm font-medium text-destructive">
Failed to load detection preview. Failed to load detection preview.
</p> </p>
<Button <Button type="button" variant="outline" size="sm" onClick={retry}>
type="button"
variant="outline"
size="sm"
onClick={() => void detectionsQuery.refetch()}
>
Retry Retry
</Button> </Button>
</div> </div>
</div> </div>
) : !activeDetection || !detectionResult ? ( ) : !activeDetection || !detectionResult ? (
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
No detections are available for this issue. {detectionCount > 0
? 'The selected detection details are unavailable. Try refreshing the preview.'
: isUnassignedPreview
? 'No unassigned issues remain on this ticket.'
: 'No detections are available for this issue.'}
</div> </div>
) : ( ) : (
<> <>
@@ -283,6 +288,8 @@ export function TicketDetectionPreview({
]} ]}
/> />
{isUnassignedPreview ? (
<>
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
type="button" type="button"
@@ -308,6 +315,8 @@ export function TicketDetectionPreview({
}} }}
/> />
</> </>
) : null}
</>
)} )}
</div> </div>
); );

View File

@@ -143,7 +143,7 @@ export function TicketOverviewCard({
<div className="space-y-5"> <div className="space-y-5">
{uploadedOn || uploaderName || locationLabel ? ( {uploadedOn || uploaderName || locationLabel ? (
<Card> <Card>
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x"> <CardContent className="grid gap-4 sm:grid-cols-3 sm:divide-x">
{uploadedOn ? ( {uploadedOn ? (
<div className="flex items-center gap-3 sm:pr-4"> <div className="flex items-center gap-3 sm:pr-4">
<CalendarDays className="size-5 shrink-0 text-muted-foreground" /> <CalendarDays className="size-5 shrink-0 text-muted-foreground" />

View File

@@ -0,0 +1,59 @@
'use client';
import { useState } from 'react';
import { ListChecks } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { TicketDetectionPreview } from './TicketDetectionPreview';
interface TicketUnassignedIssuesCardProps {
ticketId: string;
videoId?: string | null;
}
export function TicketUnassignedIssuesCard({
ticketId,
videoId,
}: TicketUnassignedIssuesCardProps) {
const [detectionCount, setDetectionCount] = useState<number>();
if (!videoId) return null;
return (
<Card className={'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">
<ListChecks className="size-5 text-primary" />
Unassigned Issues
</CardTitle>
<CardDescription className="mt-1">
Review detections awaiting assignment and discard invalid AI
results.
</CardDescription>
</div>
<Badge variant={'default'}>{detectionCount ?? 0} Unassigned</Badge>
</div>
</CardHeader>
<CardContent className={'p-5'}>
<div className={'min-w-0'}>
<TicketDetectionPreview
ticketId={ticketId}
videoId={videoId}
assignmentStatus={'unassigned'}
onDetectionCountChange={setDetectionCount}
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -5,6 +5,8 @@ import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import { TicketDetailHeader } from './components/TicketDetailHeader'; import { TicketDetailHeader } from './components/TicketDetailHeader';
import { import {
@@ -14,8 +16,8 @@ import {
import { TicketAssignmentsCard } from './components/TicketAssignmentsCard'; 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 { TicketDetectionPreviewCard } from './components/TicketDetectionPreviewCard';
import { TicketHistoryCard } from './components/TicketHistoryCard'; import { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketUnassignedIssuesCard } from './components/TicketUnassignedIssuesCard';
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents'; import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
import { import {
useTicketAssignmentsQuery, useTicketAssignmentsQuery,
@@ -79,17 +81,19 @@ export default function TicketDetailPage() {
{overview ? ( {overview ? (
<TicketAssignmentsCard <TicketAssignmentsCard
ticketId={ticketId} ticketId={ticketId}
videoId={overview.video_id ?? overview.video?.id}
initialAssignments={overview.assignments} initialAssignments={overview.assignments}
selectedAssignmentId={activeAssignmentId} selectedAssignmentId={activeAssignmentId}
onAssignmentChange={setSelectedAssignmentId} onAssignmentChange={setSelectedAssignmentId}
/> />
) : null} ) : null}
{activeAssignment ? ( {overview ? (
<TicketDetectionPreviewCard <PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<TicketUnassignedIssuesCard
ticketId={ticketId} ticketId={ticketId}
videoId={overview?.video_id ?? overview?.video?.id} videoId={overview.video_id ?? overview.video?.id}
assignmentId={activeAssignment.id}
/> />
</PermissionGuard>
) : null} ) : null}
<TicketHistoryCard ticketId={ticketId} /> <TicketHistoryCard ticketId={ticketId} />
</div> </div>

View File

@@ -113,12 +113,14 @@ export function useTicketAssignmentsQuery(
export function useTicketDetectionsQuery( export function useTicketDetectionsQuery(
videoId: string | undefined, videoId: string | undefined,
params: VideoDetectionsParams, params: VideoDetectionsParams,
enabled = true,
) { ) {
const queryParams = useMemo( const queryParams = useMemo(
() => ({ () => ({
skip: params.skip ?? 0, skip: params.skip ?? 0,
limit: params.limit ?? 1, limit: params.limit ?? 1,
assignment_id: params.assignment_id, assignment_id: params.assignment_id,
assignment_status: params.assignment_status,
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',
@@ -126,6 +128,7 @@ export function useTicketDetectionsQuery(
}), }),
[ [
params.assignment_id, params.assignment_id,
params.assignment_status,
params.class_name, params.class_name,
params.limit, params.limit,
params.min_confidence, params.min_confidence,
@@ -139,7 +142,7 @@ export function useTicketDetectionsQuery(
queryKey: ticketKeys.classDetections(videoId ?? '', queryParams), queryKey: ticketKeys.classDetections(videoId ?? '', queryParams),
queryFn: () => queryFn: () =>
videoService.getVideoDetections(videoId as string, queryParams), videoService.getVideoDetections(videoId as string, queryParams),
enabled: Boolean(videoId), enabled: Boolean(enabled && videoId),
staleTime: Infinity, staleTime: Infinity,
}); });
} }
@@ -154,6 +157,7 @@ export function useTicketClassDetectionsQuery(
export function useTicketAssignmentDetectionsQuery( export function useTicketAssignmentDetectionsQuery(
ticketId: string | undefined, ticketId: string | undefined,
params: TicketAssignmentDetectionsParams, params: TicketAssignmentDetectionsParams,
enabled = true,
) { ) {
const queryParams = useMemo( const queryParams = useMemo(
() => ({ () => ({
@@ -172,7 +176,7 @@ export function useTicketAssignmentDetectionsQuery(
ticketId as string, ticketId as string,
queryParams, queryParams,
), ),
enabled: Boolean(ticketId), enabled: Boolean(enabled && ticketId),
staleTime: Infinity, staleTime: Infinity,
}); });
} }
@@ -229,6 +233,9 @@ export function useDiscardDetectionMutation(
}), }),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }), queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }), queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
queryClient.invalidateQueries({
queryKey: ticketKeys.assignmentDetectionLists(ticketId),
}),
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId), queryKey: ticketKeys.assignments(ticketId),
}), }),

View File

@@ -74,6 +74,7 @@ export const videoService = {
skip: params?.skip ?? 0, skip: params?.skip ?? 0,
limit: params?.limit ?? 1, limit: params?.limit ?? 1,
assignment_id: params?.assignment_id, assignment_id: params?.assignment_id,
assignment_status: params?.assignment_status,
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

@@ -75,10 +75,13 @@ export type DetectionResultLog = DetectionResultItem & {
}; };
}; };
export type AssignmentDetectionStatus = 'assigned' | 'unassigned';
export type VideoDetectionsParams = { export type VideoDetectionsParams = {
skip?: number; skip?: number;
limit?: number; limit?: number;
assignment_id?: number; assignment_id?: number;
assignment_status?: AssignmentDetectionStatus;
class_name?: string | string[]; class_name?: string | string[];
proof_status?: DetectionProofStatus; proof_status?: DetectionProofStatus;
min_confidence?: number; min_confidence?: number;
@@ -104,8 +107,6 @@ export type VideoDetectionsResponse = {
items: DetectionResultItem[]; items: DetectionResultItem[];
}; };
export type AssignmentDetectionStatus = 'assigned' | 'unassigned';
export type TicketAssignmentDetectionsParams = { export type TicketAssignmentDetectionsParams = {
assignment_status?: AssignmentDetectionStatus; assignment_status?: AssignmentDetectionStatus;
class_name?: string | string[]; class_name?: string | string[];