Compare commits
3 Commits
8e8803396d
...
e8f2955acf
| Author | SHA1 | Date | |
|---|---|---|---|
| e8f2955acf | |||
| 5e66b70611 | |||
| 7ffe3f0708 |
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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