feat(ticket): support criteria-based global ticket assignment

This commit is contained in:
2026-07-28 18:47:29 +05:30
parent 0601322361
commit 8e8803396d
16 changed files with 586 additions and 107 deletions

View File

@@ -1,16 +1,22 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { X } from 'lucide-react';
import { MultiSelectPopover } from '@/components/form/MultiSelectPopover';
import { Input } from '@/components/ui/input';
import { useDebounce } from '@/hooks/useDebounce';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
useDetectionCoordinatesQuery,
useTicketDetectionsQuery,
} from '../../../hooks/useTicketQueries';
import {
formatCompactRangeCoordinate,
formatRangeCoordinate,
type AssignmentRangePoint,
} from './assignmentRange';
import { AssignmentIssuesMap } from './AssignmentIssuesMap';
import { useIssueColumns } from './IssueColumns';
import { IssueTable } from './IssueTable';
@@ -27,30 +33,66 @@ interface IssueTypeOption {
interface ChooseIssuesSectionProps {
videoId?: string;
issueTypes: IssueTypeOption[];
selectedIssueTypes: string[];
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
onSelectedIssueTypesChange: (values: string[]) => void;
onStartPointChange: (point: AssignmentRangePoint | null) => void;
onEndPointChange: (point: AssignmentRangePoint | null) => void;
}
export function ChooseIssuesSection({
videoId,
issueTypes,
selectedIssueTypes,
startPoint,
endPoint,
onSelectedIssueTypesChange,
onStartPointChange,
onEndPointChange,
}: 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 handleSetStart = useCallback(
(point: AssignmentRangePoint) => {
onStartPointChange(point);
onEndPointChange(null);
},
[onEndPointChange, onStartPointChange],
);
const handleSetEnd = useCallback(
(point: AssignmentRangePoint) => {
if (point.detectionId === startPoint?.detectionId) return;
onEndPointChange(point);
},
[onEndPointChange, startPoint?.detectionId],
);
const columns = useIssueColumns({
startPoint,
endPoint,
onSetStart: handleSetStart,
onSetEnd: handleSetEnd,
});
const issueTypeOptions = useMemo(
() =>
issueTypes.map((issue) => ({
value: issue.class_name,
label: `${issue.display_name} (${issue.count})`,
label: issue.display_name,
})),
[issueTypes],
);
const handleIssueTypesChange = useCallback(
(values: string[]) => {
onSelectedIssueTypesChange(values);
onStartPointChange(null);
onEndPointChange(null);
},
[onEndPointChange, onSelectedIssueTypesChange, onStartPointChange],
);
useEffect(() => {
setSkip(0);
}, [debouncedSearch, selectedIssueTypes]);
}, [selectedIssueTypes]);
const queryParams = useMemo(
() => ({
@@ -58,10 +100,9 @@ export function ChooseIssuesSection({
limit,
class_name:
selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined,
search: debouncedSearch || undefined,
sort: 'timestamp_asc',
}),
[debouncedSearch, limit, selectedIssueTypes, skip],
[limit, selectedIssueTypes, skip],
);
const detectionsQuery = useTicketDetectionsQuery(videoId, queryParams);
const mapQueryParams = useMemo(
@@ -69,9 +110,9 @@ export function ChooseIssuesSection({
limit: MAP_PAGE_SIZE,
class_name:
selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined,
search: debouncedSearch || undefined,
assignment_status: 'unassigned' as const,
}),
[debouncedSearch, selectedIssueTypes],
[selectedIssueTypes],
);
const coordinatesQuery = useDetectionCoordinatesQuery(
videoId,
@@ -94,34 +135,67 @@ export function ChooseIssuesSection({
return (
<div className="min-w-0 space-y-5">
<div
aria-label="Issue filters"
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>
<Card size="sm" aria-label="Issue filters">
<CardHeader>
<CardTitle>Filters</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-2">
<MultiSelectPopover
label="Issue Types"
options={issueTypeOptions}
values={selectedIssueTypes}
onValuesChange={handleIssueTypesChange}
searchPlaceholder="Search issue types"
emptyMessage="No issue types found."
emptySelectionLabel="All"
align="start"
/>
<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
id="assignment-issue-search"
value={detectionSearch}
onChange={(event) => setDetectionSearch(event.target.value)}
placeholder="Search by detection ID"
aria-label="Search by detection ID"
className="pl-9"
/>
</div>
</div>
{startPoint ? (
<Badge
variant="outline"
className="h-8 gap-1.5 px-2.5 font-normal"
title={formatRangeCoordinate(startPoint)}
>
<span className="font-medium">Start</span>
<span className="font-mono">
{formatCompactRangeCoordinate(startPoint)}
</span>
</Badge>
) : null}
{endPoint ? (
<Badge
variant="outline"
className="h-8 gap-1.5 px-2.5 font-normal"
title={formatRangeCoordinate(endPoint)}
>
<span className="font-medium">End</span>
<span className="font-mono">
{formatCompactRangeCoordinate(endPoint)}
</span>
</Badge>
) : null}
{startPoint !== null || endPoint !== null ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
aria-label="Clear coordinate range"
onClick={() => {
onStartPointChange(null);
onEndPointChange(null);
}}
>
<X />
</Button>
) : null}
</div>
</CardContent>
</Card>
<AssignmentIssuesMap
data={mapData}
@@ -129,6 +203,10 @@ export function ChooseIssuesSection({
isFetchingMore={coordinatesQuery.isFetchingNextPage}
isError={coordinatesQuery.isError}
hasMore={Boolean(coordinatesQuery.hasNextPage)}
startPoint={startPoint}
endPoint={endPoint}
onSetStart={handleSetStart}
onSetEnd={handleSetEnd}
onLoadMore={() => void coordinatesQuery.fetchNextPage()}
onRetry={() => void coordinatesQuery.refetch()}
/>
@@ -136,7 +214,7 @@ export function ChooseIssuesSection({
<IssueTable
columns={columns}
issues={result?.items ?? []}
isLoading={detectionsQuery.isLoading || detectionsQuery.isFetching}
isLoading={detectionsQuery.isLoading}
isError={detectionsQuery.isError}
toolbar={null}
skip={skip}