feat(ticket): add global ticket assignment page

This commit is contained in:
2026-07-27 18:31:12 +05:30
parent 224361f144
commit 20f611f35a
13 changed files with 540 additions and 126 deletions

View File

@@ -0,0 +1,139 @@
'use client';
import { useMemo, useState } from 'react';
import { Mail, Phone, Send, ShieldCheck, Users } from 'lucide-react';
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import type { AssignableTicketUser } from '@/types';
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
interface AssignTicketFormProps {
ticketId: string;
onAssigned: () => void;
}
export function AssignTicketForm({
ticketId,
onAssigned,
}: AssignTicketFormProps) {
const [selectedUserId, setSelectedUserId] = useState('');
const [selectedContractor, setSelectedContractor] =
useState<AssignableTicketUser | null>(null);
const [dueDate, setDueDate] = useState<Date>();
const [assignNote, setAssignNote] = useState('');
const assignMutation = useAssignTicketMutation(ticketId);
const today = useMemo(() => {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}, []);
const maxDueMonth = useMemo(
() => new Date(today.getFullYear() + 20, 11),
[today],
);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Users className="text-primary" />
Contractor and schedule
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-5">
<div className="space-y-2">
<Label>
Contractor <span className="text-destructive">*</span>
</Label>
<AssignableWorkerSelect
value={selectedUserId}
onValueChange={(userId, user) => {
setSelectedUserId(userId);
setSelectedContractor(user);
}}
disabled={assignMutation.isPending}
/>
</div>
{selectedContractor ? (
<div className="space-y-2 rounded-lg border bg-muted/30 p-4">
<p className="text-sm font-medium">Contractor details</p>
{selectedContractor.phone_number ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Phone className="size-4 shrink-0" />
{selectedContractor.phone_number}
</p>
) : null}
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="size-4 shrink-0" />
<span className="truncate">{selectedContractor.email}</span>
</p>
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<ShieldCheck className="size-4 shrink-0" />
{selectedContractor.role.name}
</p>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="assign-due-date">
Due date <span className="text-destructive">*</span>
</Label>
<DatePickerSimple
id="assign-due-date"
value={dueDate}
onChange={setDueDate}
showLabel={false}
placeholder="Select due date"
disabled={assignMutation.isPending}
className="w-full"
startMonth={today}
endMonth={maxDueMonth}
disabledDates={{ before: today }}
/>
</div>
<div className="space-y-2">
<Label htmlFor="assign-note">Assignment note (optional)</Label>
<Textarea
id="assign-note"
value={assignNote}
onChange={(event) => setAssignNote(event.target.value)}
placeholder="Add instructions or notes for the contractor..."
disabled={assignMutation.isPending}
/>
</div>
<Button
type="button"
disabled={!selectedUserId || !dueDate || assignMutation.isPending}
onClick={() => {
if (!selectedUserId || !dueDate) return;
assignMutation.mutate(
{
assigned_to_user_id: Number(selectedUserId),
due_at: dueDate.toISOString(),
note: assignNote || undefined,
},
{ onSuccess: onAssigned },
);
}}
className="w-full sm:w-auto"
>
<Send />
{assignMutation.isPending ? 'Assigning...' : 'Assign ticket'}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { MultiSelectPopover } from '@/components/form/MultiSelectPopover';
import { Input } from '@/components/ui/input';
import { useDebounce } from '@/hooks/useDebounce';
import { useTicketDetectionsQuery } from '../../../hooks/useTicketQueries';
import { useIssueColumns } from './IssueColumns';
import { IssueTable } from './IssueTable';
const DEFAULT_PAGE_SIZE = 10;
interface IssueTypeOption {
class_name: string;
display_name: string;
count: number;
}
interface ChooseIssuesSectionProps {
videoId?: string;
issueTypes: IssueTypeOption[];
}
export function ChooseIssuesSection({
videoId,
issueTypes,
}: ChooseIssuesSectionProps) {
const [selectedIssueTypes, setSelectedIssueTypes] = useState<string[]>([]);
const [detectionSearch, setDetectionSearch] = useState('');
const [skip, setSkip] = useState(0);
const [limit, setLimit] = useState(DEFAULT_PAGE_SIZE);
const debouncedSearch = useDebounce(detectionSearch.trim(), 350);
const columns = useIssueColumns();
const issueTypeOptions = useMemo(
() =>
issueTypes.map((issue) => ({
value: issue.class_name,
label: `${issue.display_name} (${issue.count})`,
})),
[issueTypes],
);
useEffect(() => {
setSkip(0);
}, [debouncedSearch, selectedIssueTypes]);
const queryParams = useMemo(
() => ({
skip,
limit,
class_name:
selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined,
search: debouncedSearch || undefined,
sort: 'timestamp_asc',
}),
[debouncedSearch, limit, selectedIssueTypes, skip],
);
const detectionsQuery = useTicketDetectionsQuery(videoId, queryParams);
const result = detectionsQuery.data;
return (
<IssueTable
columns={columns}
issues={result?.items ?? []}
isLoading={detectionsQuery.isLoading || detectionsQuery.isFetching}
isError={detectionsQuery.isError}
toolbar={
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="w-fit">
<MultiSelectPopover
label="Issue Types"
options={issueTypeOptions}
values={selectedIssueTypes}
onValuesChange={setSelectedIssueTypes}
searchPlaceholder="Search issue types"
emptyMessage="No issue types found."
align="start"
/>
</div>
<div className="relative w-full sm:ml-auto sm:w-72">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={detectionSearch}
onChange={(event) => setDetectionSearch(event.target.value)}
placeholder="Search by detection ID"
aria-label="Search by detection ID"
className="pl-9"
/>
</div>
</div>
}
skip={skip}
limit={limit}
total={result?.total ?? 0}
onPageChange={setSkip}
onLimitChange={setLimit}
/>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import type { DetectionResultItem } from '@/types';
function formatTimestamp(seconds: number) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
export function useIssueColumns(): ColumnDef<DetectionResultItem>[] {
return useMemo(
() => [
{
id: 'detection_id',
header: 'Detection ID',
size: 140,
cell: ({ row }) => `#${row.original.detection.id}`,
},
{
id: 'issue_type',
header: 'Issue Type',
size: 180,
cell: ({ row }) => row.original.detection.display_name,
},
{
id: 'confidence',
header: 'Confidence',
size: 130,
cell: ({ row }) =>
`${Math.round(row.original.detection.confidence * 100)}%`,
},
{
id: 'frame',
header: 'Frame',
size: 100,
cell: ({ row }) => row.original.frame.number,
},
{
id: 'timestamp',
header: 'Timestamp',
size: 130,
cell: ({ row }) =>
formatTimestamp(row.original.frame.timestamp_seconds),
},
],
[],
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table';
import type { DetectionResultItem } from '@/types';
interface IssueTableProps {
columns: ColumnDef<DetectionResultItem>[];
issues: DetectionResultItem[];
isLoading: boolean;
isError: boolean;
toolbar: ReactNode;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
}
export function IssueTable({
columns,
issues,
isLoading,
isError,
toolbar,
skip,
limit,
total,
onPageChange,
onLimitChange,
}: IssueTableProps) {
return (
<DataTable
title="Choose Issues"
columns={columns}
data={issues}
isLoading={isLoading}
toolbar={toolbar}
emptyTitle={isError ? 'Failed to load issues.' : 'No issues found.'}
emptyDescription={
isError
? 'Please try again.'
: 'No detections match the current filters.'
}
pagination={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange,
}}
/>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import { useParams, useRouter } from 'next/navigation';
import { AlertTriangle, ArrowLeft, LockKeyhole } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import { ROUTES } from '@/utils/routes';
import { useTicketOverviewQuery } from '../../hooks/useTicketQueries';
import { AssignTicketForm } from './components/AssignTicketForm';
import { ChooseIssuesSection } from './components/ChooseIssuesSection';
function AssignmentPageSkeleton() {
return (
<div className="space-y-5" aria-label="Loading assignment workspace">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-7 w-56" />
<Skeleton className="h-4 w-40" />
</div>
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<Skeleton className="h-[540px] rounded-xl" />
<Skeleton className="h-[430px] rounded-xl" />
</div>
</div>
);
}
export default function TicketAssignmentPage() {
const router = useRouter();
const { ticketId } = useParams() as { ticketId: string };
const overviewQuery = useTicketOverviewQuery(ticketId);
const overview = overviewQuery.data;
const videoId = overview?.video_id ?? overview?.video?.id ?? undefined;
const ticketDetailUrl = ROUTES.TICKET_DETAIL(ticketId);
const backToTicket = () => router.push(ticketDetailUrl);
if (overviewQuery.isLoading) {
return <AssignmentPageSkeleton />;
}
if (overviewQuery.isError || !overview) {
return (
<Card>
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
<AlertTriangle className="size-8 text-destructive" />
<div>
<p className="font-semibold">
Unable to open the assignment workspace.
</p>
<p className="mt-1 text-sm text-muted-foreground">
Ticket information is unavailable.
</p>
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={backToTicket}>
Back to ticket
</Button>
<Button type="button" onClick={() => void overviewQuery.refetch()}>
Retry
</Button>
</div>
</CardContent>
</Card>
);
}
const ticketLabel = overview.ticket_name || overview.id;
return (
<main className="relative z-10 space-y-5">
<header className="flex items-start gap-3">
<Button
type="button"
variant="secondary"
size="icon"
onClick={backToTicket}
aria-label="Back to ticket"
className="shrink-0"
>
<ArrowLeft />
</Button>
<div className="min-w-0 space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">
Assign ticket
</h1>
<p className="truncate text-sm text-muted-foreground">
Ticket: {ticketLabel}
</p>
</div>
</header>
<div className="grid min-w-0 items-start gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<ChooseIssuesSection
videoId={videoId}
issueTypes={overview.ai_result.detections_by_class}
/>
<aside className="min-w-0 xl:sticky xl:top-0">
<PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={
<Card>
<CardContent className="flex min-h-72 flex-col items-center justify-center text-center">
<LockKeyhole className="size-8 text-muted-foreground" />
<p className="mt-3 font-semibold">
Assignment access required
</p>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
You do not have permission to assign this ticket.
</p>
<Button
type="button"
variant="outline"
className="mt-4"
onClick={backToTicket}
>
Back to ticket
</Button>
</CardContent>
</Card>
}
>
<AssignTicketForm
ticketId={ticketId}
onAssigned={() => router.replace(ticketDetailUrl)}
/>
</PermissionGuard>
</aside>
</div>
</main>
);
}

View File

@@ -4,12 +4,18 @@ import { ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { TicketOverviewDetail } from '@/types';
import { AssignTicketAction } from './actions/AssignTicketAction';
export function TicketDetailHeader({
ticket,
canAssign = false,
}: {
ticket: TicketOverviewDetail;
canAssign?: boolean;
}) {
const router = useRouter();
const ticketLabel = ticket.ticket_name || ticket.id;
@@ -22,10 +28,12 @@ export function TicketDetailHeader({
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="secondary"
onClick={() => router.push('/ticket')}
>
{canAssign ? (
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<AssignTicketAction ticketId={ticket.id} />
</PermissionGuard>
) : null}
<Button variant="secondary" onClick={() => router.push('/ticket')}>
<ArrowLeft className="size-4" />
Back to List
</Button>

View File

@@ -5,7 +5,6 @@ import { PermissionGuard } from '@/guards';
import type { TicketDetail } from '@/types';
import { AssignedContractorAction } from './actions/AssignedContractorAction';
import { AssignTicketAction } from './actions/AssignTicketAction';
import { ClosedTicketAction } from './actions/ClosedTicketAction';
import { NoTicketAction } from './actions/NoTicketAction';
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
@@ -18,14 +17,7 @@ interface TicketStatusActionsProps {
function getTicketAction(ticket: TicketDetail) {
if (ticket.assignment_status === 'unassigned') {
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={<NoTicketAction />}
>
<AssignTicketAction ticket={ticket} />
</PermissionGuard>
);
return null;
}
if (ticket.assignment_status === 'assigned') {
@@ -71,5 +63,7 @@ function getTicketAction(ticket: TicketDetail) {
}
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
return <div className="space-y-3">{getTicketAction(ticket)}</div>;
const action = getTicketAction(ticket);
return action ? <div className="space-y-3">{action}</div> : null;
}

View File

@@ -1,111 +1,21 @@
'use client';
import { useMemo, useState } from 'react';
import { Users, Mail, Phone, Send, ShieldCheck } from 'lucide-react';
import { Users } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
import type { AssignableTicketUser, TicketDetail } from '@/types';
import { ROUTES } from '@/utils/routes';
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
import { TicketActionCard } from './TicketActionCard';
export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
const [selectedUserId, setSelectedUserId] = useState('');
const [selectedContractor, setSelectedContractor] =
useState<AssignableTicketUser | null>(null);
const [dueDate, setDueDate] = useState<Date>();
const [assignNote, setAssignNote] = useState('');
const assignMutation = useAssignTicketMutation(ticket.id);
const today = useMemo(() => {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}, []);
const maxDueMonth = useMemo(
() => new Date(today.getFullYear() + 20, 11),
[today],
);
export function AssignTicketAction({ ticketId }: { ticketId: string }) {
const router = useRouter();
return (
<TicketActionCard icon={Users} title="Assign to Contractor">
<div className="space-y-4">
<div className="space-y-2">
<Label>Contractor *</Label>
<AssignableWorkerSelect
value={selectedUserId}
onValueChange={(userId, user) => {
setSelectedUserId(userId);
setSelectedContractor(user);
}}
disabled={assignMutation.isPending}
/>
</div>
{selectedContractor ? (
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
<p className="text-sm font-medium">Contractor Details</p>
{selectedContractor.phone_number ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Phone className="size-4 shrink-0" />
{selectedContractor.phone_number}
</p>
) : null}
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="size-4 shrink-0" />
<span className="truncate">{selectedContractor.email}</span>
</p>
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<ShieldCheck className="size-4 shrink-0" />
{selectedContractor.role.name}
</p>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="assign-due-date">
Due Date <span className="text-destructive">*</span>
</Label>
<DatePickerSimple
id="assign-due-date"
value={dueDate}
onChange={setDueDate}
showLabel={false}
placeholder="Select due date"
disabled={assignMutation.isPending}
className="w-full"
startMonth={today}
endMonth={maxDueMonth}
disabledDates={{ before: today }}
/>
</div>
<div className="space-y-2">
<Label>Assignment Note (Optional)</Label>
<Textarea
value={assignNote}
onChange={(event) => setAssignNote(event.target.value)}
placeholder="Add any instructions or notes for the contractor..."
/>
</div>
<Button
disabled={!selectedUserId || !dueDate || assignMutation.isPending}
onClick={() => {
if (!selectedUserId || !dueDate) return;
assignMutation.mutate({
assigned_to_user_id: Number(selectedUserId),
defect_class: ticket.defect_class,
due_at: dueDate.toISOString(),
note: assignNote || undefined,
});
}}
className="w-full"
>
<Send />
Assign Ticket
</Button>
</div>
</TicketActionCard>
<Button
type="button"
onClick={() => router.push(ROUTES.TICKET_ASSIGN(ticketId))}
>
<Users />
Assign Ticket
</Button>
);
}

View File

@@ -61,7 +61,10 @@ export default function TicketDetailPage() {
<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">
<div className="xl:shrink-0">
{overview ? (
<TicketDetailHeader ticket={overview} />
<TicketDetailHeader
ticket={overview}
canAssign={classDetail?.status === 'unassigned'}
/>
) : (
<TicketOverviewHeaderSkeleton />
)}

View File

@@ -91,7 +91,7 @@ export function useTicketDefectClassesQuery(ticketId: string | undefined) {
});
}
export function useTicketClassDetectionsQuery(
export function useTicketDetectionsQuery(
videoId: string | undefined,
params: VideoDetectionsParams,
) {
@@ -118,10 +118,17 @@ export function useTicketClassDetectionsQuery(
queryKey: ticketKeys.classDetections(videoId ?? '', queryParams),
queryFn: () =>
videoService.getVideoDetections(videoId as string, queryParams),
enabled: Boolean(videoId && queryParams.class_name),
enabled: Boolean(videoId),
});
}
export function useTicketClassDetectionsQuery(
videoId: string | undefined,
params: VideoDetectionsParams,
) {
return useTicketDetectionsQuery(videoId, params);
}
export function useTicketClassReviewDetectionsQuery(
videoId: string | undefined,
defectClass: string | undefined,
@@ -282,13 +289,14 @@ export function useAssignTicketMutation(ticketId: string) {
return useMutation({
mutationFn: (payload: AssignTicketPayload) =>
ticketService.assignTicket(ticketId, payload),
onSuccess: (ticket, payload) => {
onSuccess: async () => {
toast.success('Ticket assigned');
queryClient.setQueryData<TicketDetail>(
ticketKeys.classDetail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket),
);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
await Promise.all([
queryClient.invalidateQueries({
queryKey: [...ticketKeys.details(), ticketId],
}),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
]);
},
onError: () => toast.error('Failed to assign ticket'),
});

View File

@@ -79,6 +79,9 @@ export const videoService = {
sort: params?.sort ?? 'timestamp_asc',
search: params?.search,
},
paramsSerializer: {
indexes: null,
},
},
);
return response.data;

View File

@@ -24,7 +24,6 @@ export interface AssignableTicketUsersResponse {
export interface AssignTicketPayload {
assigned_to_user_id: number;
defect_class: string;
due_at: string;
note?: string;
}

View File

@@ -16,6 +16,7 @@ export const ROUTES = {
UPLOAD: '/upload',
TICKET: '/ticket',
TICKET_DETAIL: (ticketId: string) => `/ticket/${ticketId}`,
TICKET_ASSIGN: (ticketId: string) => `/ticket/${ticketId}/assign`,
TICKET_CLASS_REVIEW: (ticketId: string, defectClass: string) =>
`/ticket/${ticketId}/${defectClass}/review`,
} as const;