feat(ticket): add global ticket assignment page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
139
src/app/(modules)/ticket/[ticketId]/assign/page.tsx
Normal file
139
src/app/(modules)/ticket/[ticketId]/assign/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
type="button"
|
||||
onClick={() => router.push(ROUTES.TICKET_ASSIGN(ticketId))}
|
||||
>
|
||||
<Send />
|
||||
<Users />
|
||||
Assign Ticket
|
||||
</Button>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
)}
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -79,6 +79,9 @@ export const videoService = {
|
||||
sort: params?.sort ?? 'timestamp_asc',
|
||||
search: params?.search,
|
||||
},
|
||||
paramsSerializer: {
|
||||
indexes: null,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
|
||||
@@ -24,7 +24,6 @@ export interface AssignableTicketUsersResponse {
|
||||
|
||||
export interface AssignTicketPayload {
|
||||
assigned_to_user_id: number;
|
||||
defect_class: string;
|
||||
due_at: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user