12 Commits

Author SHA1 Message Date
c13afa7554 feat(ui): improve collapsed sidebar and status action icons 2026-08-10 16:08:50 +05:30
8033450321 fix(extension-requests): replace lot terminology with assignment 2026-08-10 11:01:56 +05:30
98de8e948b feat(ticket): preserve list state across detail navigation 2026-08-07 17:56:55 +05:30
3697119656 feat(ticket): show ticket name in assignment header 2026-08-07 17:10:31 +05:30
e268b55392 style(ui): add modern thin scrollbar styling 2026-08-07 16:47:15 +05:30
da5b1009e3 feat(ticket): make assigned issue images collapsible 2026-08-07 16:43:44 +05:30
3d04995f97 perf(ticket): paginate and cache detection previews 2026-08-07 16:38:38 +05:30
d3069e1f25 feat(ui): improve review actions, table headers, and detection previews 2026-08-07 16:16:03 +05:30
47697b4421 feat(tickets): add unassigned issue triage to ticket details
- embed detection previews within work assignments
- allow discarding only unassigned detections
- improve ticket detail labels, counts, and layout
2026-08-07 15:31:47 +05:30
c8899c191e feat(ticket): show open and closed status in ticket list 2026-08-06 17:10:10 +05:30
8ab9240b77 style(ticket): use primary variants for overview actions and badges 2026-08-06 15:56:40 +05:30
f6436862c0 refactor(ticket): move back navigation before detail header 2026-08-06 15:19:56 +05:30
36 changed files with 522 additions and 157 deletions

View File

@@ -68,13 +68,13 @@ export function useExtensionRequestColumns(): ColumnDef<TicketExtensionRequest>[
() => [
{
accessorKey: 'ticket_name',
header: 'Ticket / Lot',
header: 'Ticket / Assignment',
size: 170,
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.ticket_name}</p>
<p className="text-xs text-muted-foreground">
Lot #{row.original.assignment_id}
Assignment #{row.original.assignment_id}
</p>
</div>
),

View File

@@ -90,8 +90,8 @@ export function ExtensionRequestDecisionDialog({
: 'Reject extension request'}
</DialogTitle>
<DialogDescription>
Review {request.ticket_name}, Lot #{request.assignment_id} before
confirming this decision.
Review {request.ticket_name}, Assignment #{request.assignment_id}{' '}
before confirming this decision.
</DialogDescription>
</DialogHeader>
@@ -205,8 +205,8 @@ export function ExtensionRequestDecisionDialog({
<AlertDialogTitle>Reject extension request?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to reject the extension request for{' '}
{request.ticket_name}, Lot #{request.assignment_id}? This decision
cannot be undone.
{request.ticket_name}, Assignment #{request.assignment_id}? This
decision cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View File

@@ -95,7 +95,7 @@ export default function ExtensionRequestsPage() {
<main className="relative z-10 space-y-5">
<PageHeader
title="Extension Requests"
description="Track deadline extension requests across all ticket lots."
description="Track deadline extension requests across all ticket assignments."
icon={CalendarClock}
/>

View File

@@ -11,7 +11,7 @@ const ModulesLayout = ({
}>) => {
return (
<AuthGuard>
<SidebarProvider>
<SidebarProvider defaultOpen={false}>
<AppSidebar />
<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">

View File

@@ -2,7 +2,7 @@
import type { ColumnDef, SortingState } from '@tanstack/react-table';
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 { PERMISSIONS } from '@/constants/permissions';
@@ -58,7 +58,12 @@ export function RoleTable({
},
{
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,
disabled: () => isStatusPending,
onClick: onToggleStatus,

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { AlertTriangle, ArrowLeft, LockKeyhole } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
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 (
<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 />
</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">
Assign ticket
Assign Ticket
</h1>
<p className="truncate text-sm text-muted-foreground">
Ticket: {ticketLabel}
</p>
<Badge
variant="secondary"
className="max-w-full font-mono text-xs"
title={ticketName}
>
<span className="truncate">{ticketName}</span>
</Badge>
</div>
</header>

View File

@@ -1,11 +1,22 @@
'use client';
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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Card,
CardContent,
@@ -20,9 +31,11 @@ import type { TicketActor, TicketAssignmentSummary } from '@/types';
import { formatDate } from '@/utils/date';
import { useTicketAssignmentsQuery } from '../../hooks/useTicketQueries';
import { TicketDetectionPreview } from './TicketDetectionPreview';
interface TicketAssignmentsCardProps {
ticketId: string;
videoId?: string | null;
initialAssignments?: TicketAssignmentSummary[];
selectedAssignmentId?: number;
onAssignmentChange: (assignmentId: number) => void;
@@ -87,7 +100,7 @@ function AssignmentProgress({
);
}
function AssignmentLot({
function AssignmentOverview({
assignment,
}: {
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">
<p className="mb-2 text-xs font-medium text-muted-foreground">
Lot {assignment.id}
Assignment {assignment.id}
</p>
<PersonInfo person={worker} />
</div>
@@ -138,6 +151,7 @@ function AssignmentLot({
export function TicketAssignmentsCard({
ticketId,
videoId,
initialAssignments,
selectedAssignmentId,
onAssignmentChange,
@@ -173,14 +187,17 @@ export function TicketAssignmentsCard({
<div className="min-w-0">
<CardTitle className="flex items-center gap-2">
<BriefcaseBusiness className="size-5 text-primary" />
Assignment lots
Work Assignments
</CardTitle>
<CardDescription className="mt-1">
Live ownership, criteria, and repair progress for this ticket.
Issues grouped by assignee, scope, and repair progress.
</CardDescription>
</div>
{assignmentsQuery.data ? (
<Badge variant="secondary">{assignments.length}</Badge>
<Badge variant="default">
{assignments.length}{' '}
{assignments.length === 1 ? 'Assignment' : 'Assignments'}
</Badge>
) : null}
</div>
</CardHeader>
@@ -195,7 +212,7 @@ export function TicketAssignmentsCard({
) : assignmentsQuery.isError ? (
<div className="rounded-lg border border-dashed p-6 text-center">
<p className="text-sm font-medium">
Unable to load assignment lots.
Unable to load work assignments.
</p>
<Button
type="button"
@@ -210,7 +227,7 @@ export function TicketAssignmentsCard({
) : selectedAssignment ? (
<div className="min-w-0 space-y-3">
<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"
>
<div
@@ -239,7 +256,7 @@ export function TicketAssignmentsCard({
)}
onClick={() => onAssignmentChange(assignment.id)}
>
Lot {assignment.id}
Assignment {assignment.id}
</button>
);
})}
@@ -252,14 +269,44 @@ export function TicketAssignmentsCard({
aria-labelledby={`assignment-tab-${activeAssignmentId}`}
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 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">
Assignment lots will appear here after work is allocated.
Work assignments will appear here after issues are allocated.
</p>
</div>
)}

View File

@@ -3,6 +3,7 @@
import { ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
@@ -13,17 +14,39 @@ import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
export function TicketDetailHeader({
ticket,
backHref,
}: {
ticket: TicketOverviewDetail;
backHref: string;
}) {
const router = useRouter();
const ticketLabel = ticket.ticket_name || ticket.id;
const ticketName = ticket.ticket_name || 'Unnamed ticket';
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Ticket Detail</h1>
<p className="text-xs ">Ticket: {ticketLabel}</p>
<div className="flex min-w-0 items-start gap-3">
<Button
type="button"
variant="secondary"
size="icon"
onClick={() => router.push(backHref)}
aria-label="Back to ticket list"
className="shrink-0"
>
<ArrowLeft />
</Button>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">
Ticket Detail
</h1>
<Badge
variant="secondary"
className="max-w-full font-mono text-xs"
title={ticketName}
>
<span className="truncate">{ticketName}</span>
</Badge>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
@@ -37,10 +60,6 @@ export function TicketDetailHeader({
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<AssignTicketAction ticketId={ticket.id} />
</PermissionGuard>
<Button variant="secondary" onClick={() => router.push('/ticket')}>
<ArrowLeft className="size-4" />
Back to List
</Button>
</div>
</div>
);

View File

@@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
import DetectionLocationMap from '@/components/map/detectionLocationMap';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
@@ -20,9 +19,13 @@ import { DetectionDiscardDialog } from './DetectionDiscardDialog';
interface TicketDetectionPreviewProps {
ticketId: string;
videoId?: string | null;
assignmentId: number;
assignmentId?: number;
assignmentStatus?: 'unassigned';
onDetectionCountChange?: (count: number | undefined) => void;
}
const DETECTION_PAGE_SIZE = 10;
function DetectionMetadataBar({
items,
}: {
@@ -95,32 +98,43 @@ export function TicketDetectionPreview({
ticketId,
videoId,
assignmentId,
assignmentStatus,
onDetectionCountChange,
}: TicketDetectionPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const isUnassignedPreview = assignmentStatus === 'unassigned';
const pageStart =
Math.floor(currentIndex / DETECTION_PAGE_SIZE) * DETECTION_PAGE_SIZE;
useEffect(() => {
setCurrentIndex(0);
}, [assignmentId, videoId]);
}, [assignmentId, assignmentStatus, videoId]);
const queryParams = useMemo(
() => ({
skip: currentIndex,
limit: 1,
assignment_id: assignmentId,
skip: pageStart,
limit: DETECTION_PAGE_SIZE,
assignment_id: isUnassignedPreview ? undefined : assignmentId,
assignment_status: isUnassignedPreview
? ('unassigned' as const)
: undefined,
sort: 'timestamp_asc',
}),
[assignmentId, currentIndex],
[assignmentId, isUnassignedPreview, pageStart],
);
const detectionsQuery = useTicketDetectionsQuery(
videoId ?? undefined,
queryParams,
{ keepPreviousPage: true },
);
const detectionResult = detectionsQuery.data;
const activeDetection = detectionResult?.items[0];
const activeDetection = detectionsQuery.isPlaceholderData
? undefined
: detectionResult?.items[currentIndex - pageStart];
const confirmedDetectionCount = detectionResult?.total;
const detectionCount = detectionResult?.total ?? 0;
const detectionCount = confirmedDetectionCount ?? 0;
const activeVisual = getDefectVisual(
activeDetection?.detection.class_name ?? '',
);
@@ -148,8 +162,18 @@ export function TicketDetectionPreview({
}
}, [confirmedDetectionCount, currentIndex]);
useEffect(() => {
onDetectionCountChange?.(detectionCount);
}, [detectionCount, onDetectionCountChange]);
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 (
<div className="space-y-4">
@@ -165,16 +189,8 @@ export function TicketDetectionPreview({
className={cn('size-5', activeVisual.colorClassName)}
/>
</span>
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div className="min-w-0">
<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>
@@ -215,28 +231,27 @@ export function TicketDetectionPreview({
</div>
</div>
{detectionsQuery.isLoading && !detectionsQuery.data ? (
{isInitialLoading ? (
<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="flex flex-col items-center gap-2">
<AlertTriangle className="size-6 text-destructive" />
<p className="text-sm font-medium text-destructive">
Failed to load detection preview.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void detectionsQuery.refetch()}
>
<Button type="button" variant="outline" size="sm" onClick={retry}>
Retry
</Button>
</div>
</div>
) : !activeDetection || !detectionResult ? (
<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>
) : (
<>
@@ -283,6 +298,8 @@ export function TicketDetectionPreview({
]}
/>
{isUnassignedPreview ? (
<>
<div className="flex justify-end">
<Button
type="button"
@@ -308,6 +325,8 @@ export function TicketDetectionPreview({
}}
/>
</>
) : null}
</>
)}
</div>
);

View File

@@ -22,6 +22,7 @@ export function TicketDetectionPreviewCard({
<CardContent className="p-5">
<div className="min-w-0">
<TicketDetectionPreview
key={`${videoId}:${assignmentId}`}
ticketId={ticketId}
videoId={videoId}
assignmentId={assignmentId}

View File

@@ -143,7 +143,7 @@ export function TicketOverviewCard({
<div className="space-y-5">
{uploadedOn || uploaderName || locationLabel ? (
<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 ? (
<div className="flex items-center gap-3 sm:pr-4">
<CalendarDays className="size-5 shrink-0 text-muted-foreground" />
@@ -183,7 +183,7 @@ export function TicketOverviewCard({
{analysisVideoId ? (
<CardAction>
<Button
variant="secondary"
variant="default"
onClick={() => setIsAnalysisOpen(true)}
>
<Play className="mr-2 size-4" />

View File

@@ -31,10 +31,13 @@ function SummarySkeletonCard() {
export function TicketOverviewHeaderSkeleton() {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<Skeleton className="size-10 shrink-0 rounded-lg" />
<div className="min-w-0 space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-3 w-36 max-w-full" />
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Skeleton className="h-8 w-28" />

View File

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

View File

@@ -147,7 +147,7 @@ export function ExtensionRequestHistoryDialog({
<DialogTitle>Extension Request History</DialogTitle>
<DialogDescription>
Review every deadline extension request and manager decision for
this ticket lot.
this ticket assignment.
</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-3">

View File

@@ -18,7 +18,7 @@ export function RequestExtensionAction({
<TicketActionCard
icon={Clock3}
title={canRequest ? 'Request Extension' : 'Extension Request History'}
contextLabel={`Lot ${assignmentId}`}
contextLabel={`Assignment ${assignmentId}`}
>
<TicketExtensionRequestPanel
ticketId={ticketId}

View File

@@ -115,7 +115,7 @@ function ExtensionReviewForm({
<TicketActionCard
icon={CalendarClock}
title="Extension Request Details"
contextLabel={`Lot ${assignmentId}`}
contextLabel={`Assignment ${assignmentId}`}
>
<div className="space-y-4">
<Requester request={request} />
@@ -271,7 +271,7 @@ export function ReviewExtensionRequestAction({
<TicketActionCard
icon={Clock3}
title="Extension Request"
contextLabel={`Lot ${assignmentId}`}
contextLabel={`Assignment ${assignmentId}`}
>
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />
@@ -298,7 +298,7 @@ export function ReviewExtensionRequestAction({
<TicketActionCard
icon={CalendarClock}
title="Extension Request"
contextLabel={`Lot ${assignmentId}`}
contextLabel={`Assignment ${assignmentId}`}
>
<ExtensionRequestDetails request={latestRequest} requests={requests} />
</TicketActionCard>

View File

@@ -30,7 +30,7 @@ export function TicketActionCard({
<span className="truncate">{title}</span>
</CardTitle>
{contextLabel ? (
<Badge variant="secondary" className="shrink-0">
<Badge variant="default" className="shrink-0">
{contextLabel}
</Badge>
) : null}

View File

@@ -258,7 +258,7 @@ export function TicketExtensionRequestPanel({
if (!canRequest) {
return (
<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>
);
}

View File

@@ -1,10 +1,13 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
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 {
@@ -14,8 +17,8 @@ import {
import { TicketAssignmentsCard } from './components/TicketAssignmentsCard';
import { TicketOverviewCard } from './components/TicketOverviewCard';
import { TicketStatusActions } from './components/TicketStatusActions';
import { TicketDetectionPreviewCard } from './components/TicketDetectionPreviewCard';
import { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketUnassignedIssuesCard } from './components/TicketUnassignedIssuesCard';
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
import {
useTicketAssignmentsQuery,
@@ -24,8 +27,20 @@ import {
export default function TicketDetailPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { ticketId } = useParams() as { ticketId: string };
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 overview = overviewQuery.data;
@@ -50,7 +65,7 @@ export default function TicketDetailPage() {
<Button
variant="outline"
size="sm"
onClick={() => router.push('/ticket')}
onClick={() => router.push(ticketListHref)}
>
<ArrowLeft />
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">
<div className="xl:shrink-0">
{overview ? (
<TicketDetailHeader ticket={overview} />
<TicketDetailHeader ticket={overview} backHref={ticketListHref} />
) : (
<TicketOverviewHeaderSkeleton />
)}
@@ -79,17 +94,19 @@ export default function TicketDetailPage() {
{overview ? (
<TicketAssignmentsCard
ticketId={ticketId}
videoId={overview.video_id ?? overview.video?.id}
initialAssignments={overview.assignments}
selectedAssignmentId={activeAssignmentId}
onAssignmentChange={setSelectedAssignmentId}
/>
) : null}
{activeAssignment ? (
<TicketDetectionPreviewCard
{overview ? (
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<TicketUnassignedIssuesCard
ticketId={ticketId}
videoId={overview?.video_id ?? overview?.video?.id}
assignmentId={activeAssignment.id}
videoId={overview.video_id ?? overview.video?.id}
/>
</PermissionGuard>
) : null}
<TicketHistoryCard ticketId={ticketId} />
</div>

View File

@@ -176,13 +176,9 @@ export function IssueReviewAction({
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="outline"
variant="default"
disabled={isPending}
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' ? (
<Loader2 className="animate-spin" />
@@ -193,13 +189,9 @@ export function IssueReviewAction({
</Button>
<Button
type="button"
variant="outline"
variant="destructive"
disabled={isPending}
onClick={() => void submitDecision('reject')}
className={cn(
decision === 'reject' &&
'border-destructive bg-destructive/10 text-destructive hover:bg-destructive/15',
)}
>
{isPending && decision === 'reject' ? (
<Loader2 className="animate-spin" />

View File

@@ -73,6 +73,17 @@ export function useTicketColumns(): ColumnDef<TicketListItem>[] {
meta: { disableTruncate: true },
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',
header: 'Uploaded By',

View File

@@ -99,6 +99,7 @@ function buildTicketListItem(
chainage_name: event.chainage_name ?? null,
default_defect_class: event.default_defect_class ?? null,
detection_count: event.detection_count,
is_closed: event.is_closed ?? false,
created_by_name: event.created_by_name ?? null,
created_by_email: event.created_by_email ?? null,
created_at: event.created_at ?? event.updated_at,
@@ -124,6 +125,7 @@ function mergeTicketListItem(
default_defect_class:
event.default_defect_class ?? current.default_defect_class,
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_email: event.created_by_email ?? current.created_by_email,
created_at: event.created_at ?? current.created_at,

View File

@@ -2,6 +2,7 @@
import { useMemo } from 'react';
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
@@ -28,6 +29,11 @@ import { extensionRequestKeys } from '../../extension-requests/queries/extension
const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
interface TicketDetectionsQueryOptions {
enabled?: boolean;
keepPreviousPage?: boolean;
}
interface UseTicketsQueryParams {
skip: number;
limit: number;
@@ -113,12 +119,15 @@ export function useTicketAssignmentsQuery(
export function useTicketDetectionsQuery(
videoId: string | undefined,
params: VideoDetectionsParams,
options: TicketDetectionsQueryOptions = {},
) {
const { enabled = true, keepPreviousPage = false } = options;
const queryParams = useMemo(
() => ({
skip: params.skip ?? 0,
limit: params.limit ?? 1,
assignment_id: params.assignment_id,
assignment_status: params.assignment_status,
class_name: params.class_name,
min_confidence: params.min_confidence,
sort: params.sort ?? 'timestamp_asc',
@@ -126,6 +135,7 @@ export function useTicketDetectionsQuery(
}),
[
params.assignment_id,
params.assignment_status,
params.class_name,
params.limit,
params.min_confidence,
@@ -139,7 +149,8 @@ export function useTicketDetectionsQuery(
queryKey: ticketKeys.classDetections(videoId ?? '', queryParams),
queryFn: () =>
videoService.getVideoDetections(videoId as string, queryParams),
enabled: Boolean(videoId),
enabled: Boolean(enabled && videoId),
placeholderData: keepPreviousPage ? keepPreviousData : undefined,
staleTime: Infinity,
});
}
@@ -154,6 +165,7 @@ export function useTicketClassDetectionsQuery(
export function useTicketAssignmentDetectionsQuery(
ticketId: string | undefined,
params: TicketAssignmentDetectionsParams,
enabled = true,
) {
const queryParams = useMemo(
() => ({
@@ -172,7 +184,7 @@ export function useTicketAssignmentDetectionsQuery(
ticketId as string,
queryParams,
),
enabled: Boolean(ticketId),
enabled: Boolean(enabled && ticketId),
staleTime: Infinity,
});
}
@@ -229,6 +241,9 @@ export function useDiscardDetectionMutation(
}),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
queryClient.invalidateQueries({
queryKey: ticketKeys.assignmentDetectionLists(ticketId),
}),
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
}),

View File

@@ -1,7 +1,7 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { PageHeader } from '@/components/page-header';
import { Ticket } from 'lucide-react';
import type { TicketListItem } from '@/types';
@@ -17,11 +17,48 @@ import {
useTicketSummaryQuery,
} 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() {
const router = useRouter();
const [skip, setSkip] = useState(0);
const [limit, setLimit] = useState(10);
const [segmentId, setSegmentId] = useState('');
const pathname = usePathname();
const searchParams = useSearchParams();
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();
@@ -36,16 +73,56 @@ export default function TicketPage() {
const tickets = ticketsQuery.data?.items ?? [];
const total = ticketsQuery.data?.total ?? 0;
const handleSegmentChange = useCallback((nextSegmentId: string) => {
setSegmentId(nextSegmentId);
setSkip(0);
}, []);
const handleSegmentChange = useCallback(
(nextSegmentId: string) => {
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(
(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(
@@ -81,8 +158,8 @@ export default function TicketPage() {
skip={skip}
limit={limit}
total={total}
onPageChange={setSkip}
onLimitChange={setLimit}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
onView={handleView}
/>
</main>

View File

@@ -54,7 +54,7 @@ export function useUploadListColumns(): ColumnDef<UploadListItem>[] {
},
{
id: 'uploaded_by',
header: 'Uploader',
header: 'Uploaded By',
size: 190,
cell: ({ row }) => (
<div>

View File

@@ -69,6 +69,7 @@ export function useUserColumns(): ColumnDef<AdministrationUser>[] {
header: 'Status',
cell: ({ row }) => (
<Badge
className="capitalize"
variant={
row.original.effective_status === 'active'
? 'default'

View File

@@ -2,7 +2,7 @@
import type { ReactNode } from 'react';
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 { PERMISSIONS } from '@/constants/permissions';
@@ -59,7 +59,12 @@ export function UserTable({
{
label: (user) =>
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,
disabled: (user) =>
pendingUserId === user.id || user.effective_status === 'pending',

View File

@@ -140,6 +140,40 @@
* {
@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 {

View File

@@ -14,6 +14,14 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Sidebar,
SidebarContent,
@@ -124,6 +132,7 @@ function SidebarNavItem({
item: MenuItem;
pathname: string;
}) {
const { isMobile, state } = useSidebar();
const isActive = isRouteActive(pathname, item.path);
const isChildActive =
item.children?.some((child) => isRouteActive(pathname, child.path)) ??
@@ -136,6 +145,52 @@ function SidebarNavItem({
}, [isChildActive]);
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 (
<Collapsible
asChild

View File

@@ -16,7 +16,7 @@ import { getPinnedColumnStyles } from './columnStyles';
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
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) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
@@ -39,7 +39,7 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
canSort ? header.column.getToggleSortingHandler() : undefined
}
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',
sortDirection && 'bg-muted',
)}

View File

@@ -41,7 +41,7 @@ export type ColumnAlign = 'left' | 'right';
export interface DataTableAction<TData> {
label: string | ((item: TData) => string);
icon: React.ReactNode;
icon: React.ReactNode | ((item: TData) => React.ReactNode);
onClick: (item: TData) => void;
permission?: PermissionInput;
disabled?: (item: TData) => boolean;
@@ -163,6 +163,10 @@ function DataTableContent<TData, TValue>({
typeof action.label === 'function'
? action.label(item)
: action.label;
const icon =
typeof action.icon === 'function'
? action.icon(item)
: action.icon;
return (
<TableActionButton
@@ -176,7 +180,7 @@ function DataTableContent<TData, TValue>({
action.onClick(item);
}}
>
{action.icon}
{icon}
</TableActionButton>
);
})}

View File

@@ -41,11 +41,7 @@ export default function AnnotatedDetectionImage({
]
: [];
const aspectRatio =
aspectRatioOverride ??
(videoWidth > 0 && videoHeight > 0
? `${videoWidth} / ${videoHeight}`
: '16 / 9');
const aspectRatio = aspectRatioOverride ?? '16 / 9';
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"
style={{ aspectRatio }}
>
<Image
src={objectUrl}
alt={`${detection.detection.display_name} detection`}
fill
unoptimized
sizes="(min-width: 1280px) 18rem, (min-width: 640px) 50vw, 100vw"
className="object-contain"
/>
{annotatedMedia}
<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
</span>

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ export type TicketTableStatusEvent = {
chainage_name?: string | null;
default_defect_class?: string | null;
detection_count?: number;
is_closed?: boolean;
created_by_name?: string | null;
created_by_email?: string | null;
created_at?: string;

View File

@@ -18,6 +18,7 @@ export interface TicketListItem {
chainage_name: string | null;
default_defect_class: string | null;
detection_count: number;
is_closed: boolean;
created_by_name: string | null;
created_by_email: string | null;
created_at: string;