Compare commits
10 Commits
8ab9240b77
...
global-ass
| Author | SHA1 | Date | |
|---|---|---|---|
| c13afa7554 | |||
| 8033450321 | |||
| 98de8e948b | |||
| 3697119656 | |||
| e268b55392 | |||
| da5b1009e3 | |||
| 3d04995f97 | |||
| d3069e1f25 | |||
| 47697b4421 | |||
| c8899c191e |
@@ -68,13 +68,13 @@ export function useExtensionRequestColumns(): ColumnDef<TicketExtensionRequest>[
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'ticket_name',
|
accessorKey: 'ticket_name',
|
||||||
header: 'Ticket / Lot',
|
header: 'Ticket / Assignment',
|
||||||
size: 170,
|
size: 170,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{row.original.ticket_name}</p>
|
<p className="font-medium">{row.original.ticket_name}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Lot #{row.original.assignment_id}
|
Assignment #{row.original.assignment_id}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ export function ExtensionRequestDecisionDialog({
|
|||||||
: 'Reject extension request'}
|
: 'Reject extension request'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Review {request.ticket_name}, Lot #{request.assignment_id} before
|
Review {request.ticket_name}, Assignment #{request.assignment_id}{' '}
|
||||||
confirming this decision.
|
before confirming this decision.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -205,8 +205,8 @@ export function ExtensionRequestDecisionDialog({
|
|||||||
<AlertDialogTitle>Reject extension request?</AlertDialogTitle>
|
<AlertDialogTitle>Reject extension request?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to reject the extension request for{' '}
|
Are you sure you want to reject the extension request for{' '}
|
||||||
{request.ticket_name}, Lot #{request.assignment_id}? This decision
|
{request.ticket_name}, Assignment #{request.assignment_id}? This
|
||||||
cannot be undone.
|
decision cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export default function ExtensionRequestsPage() {
|
|||||||
<main className="relative z-10 space-y-5">
|
<main className="relative z-10 space-y-5">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Extension Requests"
|
title="Extension Requests"
|
||||||
description="Track deadline extension requests across all ticket lots."
|
description="Track deadline extension requests across all ticket assignments."
|
||||||
icon={CalendarClock}
|
icon={CalendarClock}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const ModulesLayout = ({
|
|||||||
}>) => {
|
}>) => {
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<SidebarProvider>
|
<SidebarProvider defaultOpen={false}>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden">
|
<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="scroll-stable min-w-0 flex-1 overflow-auto">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Edit, RotateCcw } from 'lucide-react';
|
import { Edit, ShieldCheck, ShieldX } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
@@ -58,7 +58,12 @@ export function RoleTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: (role) => (role.effective_status ? 'Deactivate' : 'Activate'),
|
label: (role) => (role.effective_status ? 'Deactivate' : 'Activate'),
|
||||||
icon: <RotateCcw className="size-4" />,
|
icon: (role) =>
|
||||||
|
role.effective_status ? (
|
||||||
|
<ShieldX className="size-4 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<ShieldCheck className="size-4 text-emerald-600" />
|
||||||
|
),
|
||||||
permission: PERMISSIONS.ROLE.DELETE,
|
permission: PERMISSIONS.ROLE.DELETE,
|
||||||
disabled: () => isStatusPending,
|
disabled: () => isStatusPending,
|
||||||
onClick: onToggleStatus,
|
onClick: onToggleStatus,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { AlertTriangle, ArrowLeft, LockKeyhole } from 'lucide-react';
|
import { AlertTriangle, ArrowLeft, LockKeyhole } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
@@ -78,7 +79,7 @@ export default function TicketAssignmentPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ticketLabel = overview.ticket_name || overview.id;
|
const ticketName = overview.ticket_name || 'Unnamed ticket';
|
||||||
|
|
||||||
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 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">
|
||||||
@@ -93,13 +94,17 @@ export default function TicketAssignmentPage() {
|
|||||||
>
|
>
|
||||||
<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">
|
||||||
Assign ticket
|
Assign Ticket
|
||||||
</h1>
|
</h1>
|
||||||
<p className="truncate text-sm text-muted-foreground">
|
<Badge
|
||||||
Ticket: {ticketLabel}
|
variant="secondary"
|
||||||
</p>
|
className="max-w-full font-mono text-xs"
|
||||||
|
title={ticketName}
|
||||||
|
>
|
||||||
|
<span className="truncate">{ticketName}</span>
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { BriefcaseBusiness, CalendarClock, ListChecks } from 'lucide-react';
|
import {
|
||||||
|
BriefcaseBusiness,
|
||||||
|
CalendarClock,
|
||||||
|
ChevronDown,
|
||||||
|
Images,
|
||||||
|
ListChecks,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { PersonInfo } from '@/components/person-avatar';
|
import { PersonInfo } from '@/components/person-avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -20,9 +31,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 +100,7 @@ function AssignmentProgress({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssignmentLot({
|
function AssignmentOverview({
|
||||||
assignment,
|
assignment,
|
||||||
}: {
|
}: {
|
||||||
assignment: TicketAssignmentSummary;
|
assignment: TicketAssignmentSummary;
|
||||||
@@ -109,7 +122,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 +151,7 @@ function AssignmentLot({
|
|||||||
|
|
||||||
export function TicketAssignmentsCard({
|
export function TicketAssignmentsCard({
|
||||||
ticketId,
|
ticketId,
|
||||||
|
videoId,
|
||||||
initialAssignments,
|
initialAssignments,
|
||||||
selectedAssignmentId,
|
selectedAssignmentId,
|
||||||
onAssignmentChange,
|
onAssignmentChange,
|
||||||
@@ -173,14 +187,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 +212,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 +227,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 +256,7 @@ export function TicketAssignmentsCard({
|
|||||||
)}
|
)}
|
||||||
onClick={() => onAssignmentChange(assignment.id)}
|
onClick={() => onAssignmentChange(assignment.id)}
|
||||||
>
|
>
|
||||||
Lot {assignment.id}
|
Assignment {assignment.id}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -252,14 +269,44 @@ 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 ? (
|
||||||
|
<Collapsible
|
||||||
|
key={selectedAssignment.id}
|
||||||
|
className="group mt-4 overflow-hidden rounded-lg border"
|
||||||
|
>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-auto w-full justify-between rounded-none px-4 py-3"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 font-medium">
|
||||||
|
<Images className="size-4 text-primary" />
|
||||||
|
Assigned issue images
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="border-t p-4">
|
||||||
|
<TicketDetectionPreview
|
||||||
|
key={`${videoId}:${selectedAssignment.id}`}
|
||||||
|
ticketId={ticketId}
|
||||||
|
videoId={videoId}
|
||||||
|
assignmentId={selectedAssignment.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
) : 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -13,11 +14,13 @@ import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
|
|||||||
|
|
||||||
export function TicketDetailHeader({
|
export function TicketDetailHeader({
|
||||||
ticket,
|
ticket,
|
||||||
|
backHref,
|
||||||
}: {
|
}: {
|
||||||
ticket: TicketOverviewDetail;
|
ticket: TicketOverviewDetail;
|
||||||
|
backHref: string;
|
||||||
}) {
|
}) {
|
||||||
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">
|
||||||
@@ -26,17 +29,23 @@ export function TicketDetailHeader({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => router.push('/ticket')}
|
onClick={() => router.push(backHref)}
|
||||||
aria-label="Back to ticket list"
|
aria-label="Back to ticket list"
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +19,13 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DETECTION_PAGE_SIZE = 10;
|
||||||
|
|
||||||
function DetectionMetadataBar({
|
function DetectionMetadataBar({
|
||||||
items,
|
items,
|
||||||
}: {
|
}: {
|
||||||
@@ -95,32 +98,43 @@ 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';
|
||||||
|
const pageStart =
|
||||||
|
Math.floor(currentIndex / DETECTION_PAGE_SIZE) * DETECTION_PAGE_SIZE;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
}, [assignmentId, videoId]);
|
}, [assignmentId, assignmentStatus, videoId]);
|
||||||
|
|
||||||
const queryParams = useMemo(
|
const queryParams = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
skip: currentIndex,
|
skip: pageStart,
|
||||||
limit: 1,
|
limit: DETECTION_PAGE_SIZE,
|
||||||
assignment_id: assignmentId,
|
assignment_id: isUnassignedPreview ? undefined : assignmentId,
|
||||||
|
assignment_status: isUnassignedPreview
|
||||||
|
? ('unassigned' as const)
|
||||||
|
: undefined,
|
||||||
sort: 'timestamp_asc',
|
sort: 'timestamp_asc',
|
||||||
}),
|
}),
|
||||||
[assignmentId, currentIndex],
|
[assignmentId, isUnassignedPreview, pageStart],
|
||||||
);
|
);
|
||||||
|
|
||||||
const detectionsQuery = useTicketDetectionsQuery(
|
const detectionsQuery = useTicketDetectionsQuery(
|
||||||
videoId ?? undefined,
|
videoId ?? undefined,
|
||||||
queryParams,
|
queryParams,
|
||||||
|
{ keepPreviousPage: true },
|
||||||
);
|
);
|
||||||
const detectionResult = detectionsQuery.data;
|
const detectionResult = detectionsQuery.data;
|
||||||
const activeDetection = detectionResult?.items[0];
|
const activeDetection = detectionsQuery.isPlaceholderData
|
||||||
|
? undefined
|
||||||
|
: detectionResult?.items[currentIndex - pageStart];
|
||||||
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 +162,18 @@ export function TicketDetectionPreview({
|
|||||||
}
|
}
|
||||||
}, [confirmedDetectionCount, currentIndex]);
|
}, [confirmedDetectionCount, currentIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDetectionCountChange?.(detectionCount);
|
||||||
|
}, [detectionCount, onDetectionCountChange]);
|
||||||
|
|
||||||
const isNavigationDisabled =
|
const isNavigationDisabled =
|
||||||
detectionCount === 0 || detectionsQuery.isLoading;
|
detectionCount === 0 ||
|
||||||
|
detectionsQuery.isLoading ||
|
||||||
|
detectionsQuery.isPlaceholderData;
|
||||||
|
const isInitialLoading =
|
||||||
|
detectionsQuery.isLoading || detectionsQuery.isPlaceholderData;
|
||||||
|
const isError = detectionsQuery.isError;
|
||||||
|
const retry = () => void detectionsQuery.refetch();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -165,16 +189,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 +231,27 @@ export function TicketDetectionPreview({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detectionsQuery.isLoading && !detectionsQuery.data ? (
|
{isInitialLoading ? (
|
||||||
<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 +298,8 @@ export function TicketDetectionPreview({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isUnassignedPreview ? (
|
||||||
|
<>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -308,6 +325,8 @@ export function TicketDetectionPreview({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function TicketDetectionPreviewCard({
|
|||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<TicketDetectionPreview
|
<TicketDetectionPreview
|
||||||
|
key={`${videoId}:${assignmentId}`}
|
||||||
ticketId={ticketId}
|
ticketId={ticketId}
|
||||||
videoId={videoId}
|
videoId={videoId}
|
||||||
assignmentId={assignmentId}
|
assignmentId={assignmentId}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'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
|
||||||
|
key={`${videoId}:unassigned`}
|
||||||
|
ticketId={ticketId}
|
||||||
|
videoId={videoId}
|
||||||
|
assignmentStatus={'unassigned'}
|
||||||
|
onDetectionCountChange={setDetectionCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -147,7 +147,7 @@ export function ExtensionRequestHistoryDialog({
|
|||||||
<DialogTitle>Extension Request History</DialogTitle>
|
<DialogTitle>Extension Request History</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Review every deadline extension request and manager decision for
|
Review every deadline extension request and manager decision for
|
||||||
this ticket lot.
|
this ticket assignment.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogBody className="space-y-3">
|
<DialogBody className="space-y-3">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function RequestExtensionAction({
|
|||||||
<TicketActionCard
|
<TicketActionCard
|
||||||
icon={Clock3}
|
icon={Clock3}
|
||||||
title={canRequest ? 'Request Extension' : 'Extension Request History'}
|
title={canRequest ? 'Request Extension' : 'Extension Request History'}
|
||||||
contextLabel={`Lot ${assignmentId}`}
|
contextLabel={`Assignment ${assignmentId}`}
|
||||||
>
|
>
|
||||||
<TicketExtensionRequestPanel
|
<TicketExtensionRequestPanel
|
||||||
ticketId={ticketId}
|
ticketId={ticketId}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ function ExtensionReviewForm({
|
|||||||
<TicketActionCard
|
<TicketActionCard
|
||||||
icon={CalendarClock}
|
icon={CalendarClock}
|
||||||
title="Extension Request Details"
|
title="Extension Request Details"
|
||||||
contextLabel={`Lot ${assignmentId}`}
|
contextLabel={`Assignment ${assignmentId}`}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Requester request={request} />
|
<Requester request={request} />
|
||||||
@@ -271,7 +271,7 @@ export function ReviewExtensionRequestAction({
|
|||||||
<TicketActionCard
|
<TicketActionCard
|
||||||
icon={Clock3}
|
icon={Clock3}
|
||||||
title="Extension Request"
|
title="Extension Request"
|
||||||
contextLabel={`Lot ${assignmentId}`}
|
contextLabel={`Assignment ${assignmentId}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
@@ -298,7 +298,7 @@ export function ReviewExtensionRequestAction({
|
|||||||
<TicketActionCard
|
<TicketActionCard
|
||||||
icon={CalendarClock}
|
icon={CalendarClock}
|
||||||
title="Extension Request"
|
title="Extension Request"
|
||||||
contextLabel={`Lot ${assignmentId}`}
|
contextLabel={`Assignment ${assignmentId}`}
|
||||||
>
|
>
|
||||||
<ExtensionRequestDetails request={latestRequest} requests={requests} />
|
<ExtensionRequestDetails request={latestRequest} requests={requests} />
|
||||||
</TicketActionCard>
|
</TicketActionCard>
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ export function TicketExtensionRequestPanel({
|
|||||||
if (!canRequest) {
|
if (!canRequest) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No extension request history is available for this ticket lot.
|
No extension request history is available for this ticket assignment.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter, useSearchParams } 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 { ROUTES } from '@/utils/routes';
|
||||||
|
|
||||||
import { TicketDetailHeader } from './components/TicketDetailHeader';
|
import { TicketDetailHeader } from './components/TicketDetailHeader';
|
||||||
import {
|
import {
|
||||||
@@ -14,8 +17,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,
|
||||||
@@ -24,8 +27,20 @@ import {
|
|||||||
|
|
||||||
export default function TicketDetailPage() {
|
export default function TicketDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { ticketId } = useParams() as { ticketId: string };
|
const { ticketId } = useParams() as { ticketId: string };
|
||||||
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>();
|
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>();
|
||||||
|
const listParams = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const key of ['page', 'limit', 'segment']) {
|
||||||
|
const value = searchParams.get(key);
|
||||||
|
if (value) listParams.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listSearch = listParams.toString();
|
||||||
|
const ticketListHref = listSearch
|
||||||
|
? `${ROUTES.TICKET}?${listSearch}`
|
||||||
|
: ROUTES.TICKET;
|
||||||
|
|
||||||
const overviewQuery = useTicketOverviewQuery(ticketId);
|
const overviewQuery = useTicketOverviewQuery(ticketId);
|
||||||
const overview = overviewQuery.data;
|
const overview = overviewQuery.data;
|
||||||
@@ -50,7 +65,7 @@ export default function TicketDetailPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => router.push('/ticket')}
|
onClick={() => router.push(ticketListHref)}
|
||||||
>
|
>
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
Back to Tickets
|
Back to Tickets
|
||||||
@@ -63,7 +78,7 @@ export default function TicketDetailPage() {
|
|||||||
<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">
|
<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} backHref={ticketListHref} />
|
||||||
) : (
|
) : (
|
||||||
<TicketOverviewHeaderSkeleton />
|
<TicketOverviewHeaderSkeleton />
|
||||||
)}
|
)}
|
||||||
@@ -79,17 +94,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>
|
||||||
|
|||||||
@@ -176,13 +176,9 @@ export function IssueReviewAction({
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="default"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => void submitDecision('approve')}
|
onClick={() => void submitDecision('approve')}
|
||||||
className={cn(
|
|
||||||
decision === 'approve' &&
|
|
||||||
'border-emerald-500 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{isPending && decision === 'approve' ? (
|
{isPending && decision === 'approve' ? (
|
||||||
<Loader2 className="animate-spin" />
|
<Loader2 className="animate-spin" />
|
||||||
@@ -193,13 +189,9 @@ export function IssueReviewAction({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="destructive"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => void submitDecision('reject')}
|
onClick={() => void submitDecision('reject')}
|
||||||
className={cn(
|
|
||||||
decision === 'reject' &&
|
|
||||||
'border-destructive bg-destructive/10 text-destructive hover:bg-destructive/15',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{isPending && decision === 'reject' ? (
|
{isPending && decision === 'reject' ? (
|
||||||
<Loader2 className="animate-spin" />
|
<Loader2 className="animate-spin" />
|
||||||
|
|||||||
@@ -73,6 +73,17 @@ export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
|||||||
meta: { disableTruncate: true },
|
meta: { disableTruncate: true },
|
||||||
cell: ({ row }) => <TicketAttention ticket={row.original} />,
|
cell: ({ row }) => <TicketAttention ticket={row.original} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'is_closed',
|
||||||
|
header: 'Status',
|
||||||
|
size: 100,
|
||||||
|
meta: { disableTruncate: true },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant={row.original.is_closed ? 'secondary' : 'default'}>
|
||||||
|
{row.original.is_closed ? 'Closed' : 'Open'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'created_by',
|
id: 'created_by',
|
||||||
header: 'Uploaded By',
|
header: 'Uploaded By',
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ function buildTicketListItem(
|
|||||||
chainage_name: event.chainage_name ?? null,
|
chainage_name: event.chainage_name ?? null,
|
||||||
default_defect_class: event.default_defect_class ?? null,
|
default_defect_class: event.default_defect_class ?? null,
|
||||||
detection_count: event.detection_count,
|
detection_count: event.detection_count,
|
||||||
|
is_closed: event.is_closed ?? false,
|
||||||
created_by_name: event.created_by_name ?? null,
|
created_by_name: event.created_by_name ?? null,
|
||||||
created_by_email: event.created_by_email ?? null,
|
created_by_email: event.created_by_email ?? null,
|
||||||
created_at: event.created_at ?? event.updated_at,
|
created_at: event.created_at ?? event.updated_at,
|
||||||
@@ -124,6 +125,7 @@ function mergeTicketListItem(
|
|||||||
default_defect_class:
|
default_defect_class:
|
||||||
event.default_defect_class ?? current.default_defect_class,
|
event.default_defect_class ?? current.default_defect_class,
|
||||||
detection_count: event.detection_count ?? current.detection_count,
|
detection_count: event.detection_count ?? current.detection_count,
|
||||||
|
is_closed: event.is_closed ?? current.is_closed,
|
||||||
created_by_name: event.created_by_name ?? current.created_by_name,
|
created_by_name: event.created_by_name ?? current.created_by_name,
|
||||||
created_by_email: event.created_by_email ?? current.created_by_email,
|
created_by_email: event.created_by_email ?? current.created_by_email,
|
||||||
created_at: event.created_at ?? current.created_at,
|
created_at: event.created_at ?? current.created_at,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
|
keepPreviousData,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
@@ -28,6 +29,11 @@ import { extensionRequestKeys } from '../../extension-requests/queries/extension
|
|||||||
|
|
||||||
const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
|
const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
interface TicketDetectionsQueryOptions {
|
||||||
|
enabled?: boolean;
|
||||||
|
keepPreviousPage?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseTicketsQueryParams {
|
interface UseTicketsQueryParams {
|
||||||
skip: number;
|
skip: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
@@ -113,12 +119,15 @@ export function useTicketAssignmentsQuery(
|
|||||||
export function useTicketDetectionsQuery(
|
export function useTicketDetectionsQuery(
|
||||||
videoId: string | undefined,
|
videoId: string | undefined,
|
||||||
params: VideoDetectionsParams,
|
params: VideoDetectionsParams,
|
||||||
|
options: TicketDetectionsQueryOptions = {},
|
||||||
) {
|
) {
|
||||||
|
const { enabled = true, keepPreviousPage = false } = options;
|
||||||
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 +135,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 +149,8 @@ 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),
|
||||||
|
placeholderData: keepPreviousPage ? keepPreviousData : undefined,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -154,6 +165,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 +184,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 +241,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),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { Ticket } from 'lucide-react';
|
import { Ticket } from 'lucide-react';
|
||||||
import type { TicketListItem } from '@/types';
|
import type { TicketListItem } from '@/types';
|
||||||
@@ -17,11 +17,48 @@ import {
|
|||||||
useTicketSummaryQuery,
|
useTicketSummaryQuery,
|
||||||
} from './hooks/useTicketQueries';
|
} from './hooks/useTicketQueries';
|
||||||
|
|
||||||
|
const DEFAULT_PAGE = 1;
|
||||||
|
const DEFAULT_LIMIT = 10;
|
||||||
|
const PAGE_SIZE_OPTIONS = new Set([10, 20, 40, 50, 100]);
|
||||||
|
|
||||||
|
function parsePage(value: string | null) {
|
||||||
|
const page = Number(value);
|
||||||
|
return Number.isInteger(page) && page > 0 ? page : DEFAULT_PAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLimit(value: string | null) {
|
||||||
|
const limit = Number(value);
|
||||||
|
return PAGE_SIZE_OPTIONS.has(limit) ? limit : DEFAULT_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
export default function TicketPage() {
|
export default function TicketPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [skip, setSkip] = useState(0);
|
const pathname = usePathname();
|
||||||
const [limit, setLimit] = useState(10);
|
const searchParams = useSearchParams();
|
||||||
const [segmentId, setSegmentId] = useState('');
|
const searchParamsString = searchParams.toString();
|
||||||
|
const latestSearchParamsRef = useRef(searchParamsString);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
latestSearchParamsRef.current = searchParamsString;
|
||||||
|
}, [searchParamsString]);
|
||||||
|
|
||||||
|
const page = parsePage(searchParams.get('page'));
|
||||||
|
const limit = parseLimit(searchParams.get('limit'));
|
||||||
|
const segmentId = searchParams.get('segment') ?? '';
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const replaceListParams = useCallback(
|
||||||
|
(update: (params: URLSearchParams) => void) => {
|
||||||
|
const nextParams = new URLSearchParams(latestSearchParamsRef.current);
|
||||||
|
update(nextParams);
|
||||||
|
const nextSearch = nextParams.toString();
|
||||||
|
latestSearchParamsRef.current = nextSearch;
|
||||||
|
router.replace(nextSearch ? `${pathname}?${nextSearch}` : pathname, {
|
||||||
|
scroll: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[pathname, router],
|
||||||
|
);
|
||||||
|
|
||||||
useTenantTicketTableEvents();
|
useTenantTicketTableEvents();
|
||||||
|
|
||||||
@@ -36,16 +73,56 @@ export default function TicketPage() {
|
|||||||
const tickets = ticketsQuery.data?.items ?? [];
|
const tickets = ticketsQuery.data?.items ?? [];
|
||||||
const total = ticketsQuery.data?.total ?? 0;
|
const total = ticketsQuery.data?.total ?? 0;
|
||||||
|
|
||||||
const handleSegmentChange = useCallback((nextSegmentId: string) => {
|
const handleSegmentChange = useCallback(
|
||||||
setSegmentId(nextSegmentId);
|
(nextSegmentId: string) => {
|
||||||
setSkip(0);
|
replaceListParams((params) => {
|
||||||
}, []);
|
params.delete('page');
|
||||||
|
if (nextSegmentId) {
|
||||||
|
params.set('segment', nextSegmentId);
|
||||||
|
} else {
|
||||||
|
params.delete('segment');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[replaceListParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(nextSkip: number) => {
|
||||||
|
const nextPage = Math.floor(nextSkip / limit) + 1;
|
||||||
|
replaceListParams((params) => {
|
||||||
|
if (nextPage === DEFAULT_PAGE) {
|
||||||
|
params.delete('page');
|
||||||
|
} else {
|
||||||
|
params.set('page', String(nextPage));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[limit, replaceListParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLimitChange = useCallback(
|
||||||
|
(nextLimit: number) => {
|
||||||
|
replaceListParams((params) => {
|
||||||
|
params.delete('page');
|
||||||
|
if (nextLimit === DEFAULT_LIMIT) {
|
||||||
|
params.delete('limit');
|
||||||
|
} else {
|
||||||
|
params.set('limit', String(nextLimit));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[replaceListParams],
|
||||||
|
);
|
||||||
|
|
||||||
const handleView = useCallback(
|
const handleView = useCallback(
|
||||||
(ticket: TicketListItem) => {
|
(ticket: TicketListItem) => {
|
||||||
router.push(ROUTES.TICKET_DETAIL(ticket.id));
|
const detailPath = ROUTES.TICKET_DETAIL(ticket.id);
|
||||||
|
router.push(
|
||||||
|
searchParamsString ? `${detailPath}?${searchParamsString}` : detailPath,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[router],
|
[router, searchParamsString],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toolbar = useMemo(
|
const toolbar = useMemo(
|
||||||
@@ -81,8 +158,8 @@ export default function TicketPage() {
|
|||||||
skip={skip}
|
skip={skip}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
total={total}
|
total={total}
|
||||||
onPageChange={setSkip}
|
onPageChange={handlePageChange}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={handleLimitChange}
|
||||||
onView={handleView}
|
onView={handleView}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
import { Edit, RotateCcw } from 'lucide-react';
|
import { Edit, UserCheck, UserX } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
@@ -59,7 +59,12 @@ export function UserTable({
|
|||||||
{
|
{
|
||||||
label: (user) =>
|
label: (user) =>
|
||||||
user.effective_status === 'active' ? 'Deactivate' : 'Activate',
|
user.effective_status === 'active' ? 'Deactivate' : 'Activate',
|
||||||
icon: <RotateCcw className="size-4" />,
|
icon: (user) =>
|
||||||
|
user.effective_status === 'active' ? (
|
||||||
|
<UserX className="size-4 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<UserCheck className="size-4 text-emerald-600" />
|
||||||
|
),
|
||||||
permission: PERMISSIONS.USER.DELETE,
|
permission: PERMISSIONS.USER.DELETE,
|
||||||
disabled: (user) =>
|
disabled: (user) =>
|
||||||
pendingUserId === user.id || user.effective_status === 'pending',
|
pendingUserId === user.id || user.effective_status === 'pending',
|
||||||
|
|||||||
@@ -140,6 +140,40 @@
|
|||||||
|
|
||||||
* {
|
* {
|
||||||
@apply outline-none border-border outline-ring/50;
|
@apply outline-none border-border outline-ring/50;
|
||||||
|
scrollbar-color: color-mix(
|
||||||
|
in oklab,
|
||||||
|
var(--muted-foreground) 45%,
|
||||||
|
transparent
|
||||||
|
)
|
||||||
|
transparent;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track,
|
||||||
|
*::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: color-mix(
|
||||||
|
in oklab,
|
||||||
|
var(--muted-foreground) 45%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: color-mix(
|
||||||
|
in oklab,
|
||||||
|
var(--muted-foreground) 70%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from '@/components/ui/collapsible';
|
} from '@/components/ui/collapsible';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -124,6 +132,7 @@ function SidebarNavItem({
|
|||||||
item: MenuItem;
|
item: MenuItem;
|
||||||
pathname: string;
|
pathname: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { isMobile, state } = useSidebar();
|
||||||
const isActive = isRouteActive(pathname, item.path);
|
const isActive = isRouteActive(pathname, item.path);
|
||||||
const isChildActive =
|
const isChildActive =
|
||||||
item.children?.some((child) => isRouteActive(pathname, child.path)) ??
|
item.children?.some((child) => isRouteActive(pathname, child.path)) ??
|
||||||
@@ -136,6 +145,52 @@ function SidebarNavItem({
|
|||||||
}, [isChildActive]);
|
}, [isChildActive]);
|
||||||
|
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
|
if (state === 'collapsed' && !isMobile) {
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={isChildActive}
|
||||||
|
aria-label={item.title}
|
||||||
|
>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
<ChevronRight className="ml-auto" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
side="right"
|
||||||
|
align="start"
|
||||||
|
sideOffset={8}
|
||||||
|
className="min-w-48"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{item.children?.map((child) =>
|
||||||
|
child.path ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={child.title}
|
||||||
|
asChild
|
||||||
|
className={
|
||||||
|
isRouteActive(pathname, child.path)
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Link href={child.path}>
|
||||||
|
<child.icon />
|
||||||
|
<span>{child.title}</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible
|
<Collapsible
|
||||||
asChild
|
asChild
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { getPinnedColumnStyles } from './columnStyles';
|
|||||||
|
|
||||||
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
||||||
return (
|
return (
|
||||||
<ShadTableHeader className="sticky top-0 z-20 bg-muted/70 backdrop-blur supports-backdrop-filter:bg-muted/60">
|
<ShadTableHeader className="sticky top-0 z-20 bg-muted">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
@@ -39,7 +39,7 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
|||||||
canSort ? header.column.getToggleSortingHandler() : undefined
|
canSort ? header.column.getToggleSortingHandler() : undefined
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-11 max-w-0 overflow-hidden border-b border-border bg-muted/70 px-4 text-sm font-semibold text-foreground transition-colors',
|
'h-11 max-w-0 overflow-hidden border-b border-border bg-muted px-4 text-sm font-semibold text-foreground transition-colors',
|
||||||
canSort && 'cursor-pointer select-none hover:bg-muted',
|
canSort && 'cursor-pointer select-none hover:bg-muted',
|
||||||
sortDirection && 'bg-muted',
|
sortDirection && 'bg-muted',
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export type ColumnAlign = 'left' | 'right';
|
|||||||
|
|
||||||
export interface DataTableAction<TData> {
|
export interface DataTableAction<TData> {
|
||||||
label: string | ((item: TData) => string);
|
label: string | ((item: TData) => string);
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode | ((item: TData) => React.ReactNode);
|
||||||
onClick: (item: TData) => void;
|
onClick: (item: TData) => void;
|
||||||
permission?: PermissionInput;
|
permission?: PermissionInput;
|
||||||
disabled?: (item: TData) => boolean;
|
disabled?: (item: TData) => boolean;
|
||||||
@@ -163,6 +163,10 @@ function DataTableContent<TData, TValue>({
|
|||||||
typeof action.label === 'function'
|
typeof action.label === 'function'
|
||||||
? action.label(item)
|
? action.label(item)
|
||||||
: action.label;
|
: action.label;
|
||||||
|
const icon =
|
||||||
|
typeof action.icon === 'function'
|
||||||
|
? action.icon(item)
|
||||||
|
: action.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableActionButton
|
<TableActionButton
|
||||||
@@ -176,7 +180,7 @@ function DataTableContent<TData, TValue>({
|
|||||||
action.onClick(item);
|
action.onClick(item);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{action.icon}
|
{icon}
|
||||||
</TableActionButton>
|
</TableActionButton>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -41,11 +41,7 @@ export default function AnnotatedDetectionImage({
|
|||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const aspectRatio =
|
const aspectRatio = aspectRatioOverride ?? '16 / 9';
|
||||||
aspectRatioOverride ??
|
|
||||||
(videoWidth > 0 && videoHeight > 0
|
|
||||||
? `${videoWidth} / ${videoHeight}`
|
|
||||||
: '16 / 9');
|
|
||||||
|
|
||||||
const annotatedMedia = objectUrl ? (
|
const annotatedMedia = objectUrl ? (
|
||||||
<>
|
<>
|
||||||
@@ -75,14 +71,7 @@ export default function AnnotatedDetectionImage({
|
|||||||
className="group relative flex w-full cursor-zoom-in items-center justify-center overflow-hidden rounded-lg border bg-black focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="group relative flex w-full cursor-zoom-in items-center justify-center overflow-hidden rounded-lg border bg-black focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
style={{ aspectRatio }}
|
style={{ aspectRatio }}
|
||||||
>
|
>
|
||||||
<Image
|
{annotatedMedia}
|
||||||
src={objectUrl}
|
|
||||||
alt={`${detection.detection.display_name} detection`}
|
|
||||||
fill
|
|
||||||
unoptimized
|
|
||||||
sizes="(min-width: 1280px) 18rem, (min-width: 640px) 50vw, 100vw"
|
|
||||||
className="object-contain"
|
|
||||||
/>
|
|
||||||
<span className="pointer-events-none absolute inset-x-0 bottom-0 z-[3] bg-gradient-to-t from-black/70 to-transparent px-3 pb-2 pt-8 text-center text-xs font-medium text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
<span className="pointer-events-none absolute inset-x-0 bottom-0 z-[3] bg-gradient-to-t from-black/70 to-transparent px-3 pb-2 pt-8 text-center text-xs font-medium text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||||
Click to view annotated image
|
Click to view annotated image
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type TicketTableStatusEvent = {
|
|||||||
chainage_name?: string | null;
|
chainage_name?: string | null;
|
||||||
default_defect_class?: string | null;
|
default_defect_class?: string | null;
|
||||||
detection_count?: number;
|
detection_count?: number;
|
||||||
|
is_closed?: boolean;
|
||||||
created_by_name?: string | null;
|
created_by_name?: string | null;
|
||||||
created_by_email?: string | null;
|
created_by_email?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface TicketListItem {
|
|||||||
chainage_name: string | null;
|
chainage_name: string | null;
|
||||||
default_defect_class: string | null;
|
default_defect_class: string | null;
|
||||||
detection_count: number;
|
detection_count: number;
|
||||||
|
is_closed: boolean;
|
||||||
created_by_name: string | null;
|
created_by_name: string | null;
|
||||||
created_by_email: string | null;
|
created_by_email: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user