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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user