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,
}}
/>
);
}