feat(ticket): scope issue preview to selected assignment lot

This commit is contained in:
2026-07-29 15:44:19 +05:30
parent 7ffe3f0708
commit 5e66b70611
8 changed files with 166 additions and 178 deletions

View File

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

View File

@@ -1,5 +1,6 @@
'use client';
import { useEffect } from 'react';
import { BriefcaseBusiness, CalendarClock, ListChecks } from 'lucide-react';
import { PersonInfo } from '@/components/person-avatar';
@@ -14,6 +15,7 @@ import {
} 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';
@@ -22,6 +24,8 @@ import { useTicketAssignmentsQuery } from '../../hooks/useTicketQueries';
interface TicketAssignmentsCardProps {
ticketId: string;
initialAssignments?: TicketAssignmentSummary[];
selectedAssignmentId?: number;
onAssignmentChange: (assignmentId: number) => void;
}
function formatLabel(value: string) {
@@ -60,22 +64,22 @@ function AssignmentProgress({
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">
<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="grid grid-cols-3 gap-2 text-xs">
<span className="text-emerald-600">
<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="text-amber-600">
<span className="whitespace-nowrap text-amber-600">
{assignment.pending_detections} pending
</span>
<span className="text-muted-foreground">
<span className="whitespace-nowrap text-muted-foreground">
{assignment.not_submitted_detections} not submitted
</span>
</div>
@@ -102,7 +106,7 @@ function AssignmentLot({
};
return (
<div className="space-y-4 rounded-lg border p-4">
<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}
@@ -110,7 +114,7 @@ function AssignmentLot({
<PersonInfo person={worker} />
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
{classNames.map((className) => (
<AssignmentClassBadge key={className} className={className} />
))}
@@ -120,7 +124,7 @@ function AssignmentLot({
{assignment.item_count === 1 ? 'issue' : 'issues'}
</span>
{assignment.due_at ? (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<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>
@@ -135,18 +139,36 @@ function AssignmentLot({
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>
<CardHeader>
<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>
<div className="min-w-0">
<CardTitle className="flex items-center gap-2">
<BriefcaseBusiness className="size-5 text-primary" />
Assignment lots
@@ -160,7 +182,7 @@ export function TicketAssignmentsCard({
) : null}
</div>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="min-w-0 space-y-3 overflow-hidden px-3 sm:px-6">
{assignmentsQuery.isLoading ? (
Array.from({ length: 2 }).map((_, index) => (
<div
@@ -183,10 +205,54 @@ export function TicketAssignmentsCard({
Try again
</Button>
</div>
) : assignments.length ? (
assignments.map((assignment) => (
<AssignmentLot key={assignment.id} assignment={assignment} />
))
) : 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>

View File

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

View File

@@ -1,14 +1,9 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useEffect } from 'react';
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 { 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 { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
@@ -17,148 +12,59 @@ import { TicketClassDetectionPreview } from './TicketClassDetectionPreview';
interface TicketDefectClassTabsProps {
ticketId: string;
ticket?: TicketOverviewDetail;
assignmentId?: number;
}
export function TicketDefectClassTabs({
ticketId,
ticket,
assignmentId,
}: TicketDefectClassTabsProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const defectClassParam = searchParams.get('defect_class');
const defectClassesQuery = useTicketDefectClassesQuery(ticketId);
const defectClasses = useMemo(
() => defectClassesQuery.data?.defect_classes ?? [],
[defectClassesQuery.data?.defect_classes],
);
const [selectedClass, setSelectedClass] = useState<string>();
useEffect(() => {
const defectClasses = defectClassesQuery.data?.defect_classes ?? [];
if (!defectClasses.length) return;
const hasUrlClass = defectClasses.some(
(item) => item.class_name === defectClassParam,
);
if (hasUrlClass) {
setSelectedClass(defectClassParam ?? undefined);
return;
}
if (hasUrlClass) return;
const firstClass = defectClasses[0].class_name;
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('defect_class', firstClass);
setSelectedClass(firstClass);
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;
if (defectClassesQuery.isLoading) {
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;
}
if (!previewVideoId) return null;
return (
<Tabs
value={selectedClass}
onValueChange={handleClassChange}
orientation="vertical"
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">
<div className="space-y-4 px-5">
<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">
<CardContent className="p-5">
<div className="min-w-0">
<TicketClassDetectionPreview
ticketId={ticketId}
videoId={previewVideoId}
defectClassName={selectedClass}
displayName={previewDisplayName}
totalCount={selectedIssue?.count}
assignmentId={assignmentId}
totalCount={ticket?.ai_result.detection_count}
/>
</div>
</CardContent>
</Card>
</Tabs>
);
}

View File

@@ -1,5 +1,6 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
@@ -31,11 +32,14 @@ export default function TicketDetailPage() {
const searchParams = useSearchParams();
const { ticketId } = useParams() as { ticketId: string };
const defectClass = searchParams.get('defect_class') ?? undefined;
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>();
const overviewQuery = useTicketOverviewQuery(ticketId);
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
const overview = overviewQuery.data;
const classDetail = classDetailQuery.data;
const activeAssignmentId =
selectedAssignmentId ?? overview?.assignments?.[0]?.id;
const isClassDetailLoading = Boolean(
defectClass && classDetailQuery.isLoading,
);
@@ -59,7 +63,7 @@ export default function TicketDetailPage() {
}
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">
{overview ? (
<TicketDetailHeader ticket={overview} />
@@ -68,8 +72,8 @@ export default function TicketDetailPage() {
)}
</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="space-y-5 xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
<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="min-w-0 space-y-5 xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
{overview ? (
<TicketOverviewCard ticket={overview} />
) : (
@@ -79,16 +83,22 @@ export default function TicketDetailPage() {
<TicketAssignmentsCard
ticketId={ticketId}
initialAssignments={overview.assignments}
selectedAssignmentId={activeAssignmentId}
onAssignmentChange={setSelectedAssignmentId}
/>
) : null}
<TicketDefectClassTabs ticketId={ticketId} ticket={overview} />
<TicketDefectClassTabs
ticketId={ticketId}
ticket={overview}
assignmentId={activeAssignmentId}
/>
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
{!isClassDetailLoading && classDetail ? (
<TicketHistoryCard ticket={classDetail} />
) : null}
</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 && classDetail ? (
<TicketStatusActions ticket={classDetail} />

View File

@@ -120,12 +120,14 @@ export function useTicketDetectionsQuery(
() => ({
skip: params.skip ?? 0,
limit: params.limit ?? 1,
assignment_id: params.assignment_id,
class_name: params.class_name,
min_confidence: params.min_confidence,
sort: params.sort ?? 'timestamp_asc',
search: params.search,
}),
[
params.assignment_id,
params.class_name,
params.limit,
params.min_confidence,
@@ -220,7 +222,7 @@ export function useTicketClassReviewDetectionsQuery(
export function useDiscardDetectionMutation(
ticketId: string,
videoId: string | undefined,
defectClass: string,
defectClass?: string,
) {
const queryClient = useQueryClient();
@@ -244,9 +246,13 @@ export function useDiscardDetectionMutation(
queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId),
}),
...(defectClass
? [
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass),
}),
]
: []),
queryClient.invalidateQueries({
queryKey: ticketKeys.defectClasses(ticketId),
}),

View File

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

View File

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