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,7 +1,15 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Mail, Phone, Send, ShieldCheck, Users } from 'lucide-react'; import {
ArrowRight,
Mail,
MapPin,
Phone,
Send,
ShieldCheck,
Users,
} from 'lucide-react';
import { DatePickerSimple } from '@/components/form/DatePickerSimple'; import { DatePickerSimple } from '@/components/form/DatePickerSimple';
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect'; import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
@@ -12,14 +20,27 @@ import { Textarea } from '@/components/ui/textarea';
import type { AssignableTicketUser } from '@/types'; import type { AssignableTicketUser } from '@/types';
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries'; import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
import {
formatCompactRangeCoordinate,
formatRangeCoordinate,
type AssignmentRangePoint,
} from './assignmentRange';
interface AssignTicketFormProps { interface AssignTicketFormProps {
ticketId: string; ticketId: string;
videoId?: string;
selectedClassNames: string[];
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
onAssigned: () => void; onAssigned: () => void;
} }
export function AssignTicketForm({ export function AssignTicketForm({
ticketId, ticketId,
videoId,
selectedClassNames,
startPoint,
endPoint,
onAssigned, onAssigned,
}: AssignTicketFormProps) { }: AssignTicketFormProps) {
const [selectedUserId, setSelectedUserId] = useState(''); const [selectedUserId, setSelectedUserId] = useState('');
@@ -28,7 +49,7 @@ export function AssignTicketForm({
const [dueDate, setDueDate] = useState<Date>(); const [dueDate, setDueDate] = useState<Date>();
const [assignNote, setAssignNote] = useState(''); const [assignNote, setAssignNote] = useState('');
const assignMutation = useAssignTicketMutation(ticketId); const assignMutation = useAssignTicketMutation(ticketId, videoId);
const today = useMemo(() => { const today = useMemo(() => {
const date = new Date(); const date = new Date();
date.setHours(0, 0, 0, 0); date.setHours(0, 0, 0, 0);
@@ -38,6 +59,7 @@ export function AssignTicketForm({
() => new Date(today.getFullYear() + 20, 11), () => new Date(today.getFullYear() + 20, 11),
[today], [today],
); );
const hasIncompleteLocationRange = Boolean(startPoint) !== Boolean(endPoint);
return ( return (
<Card> <Card>
@@ -83,6 +105,45 @@ export function AssignTicketForm({
</div> </div>
) : null} ) : null}
{startPoint || endPoint ? (
<div className="space-y-2">
<Label>Location</Label>
<div
className="flex min-w-0 items-center gap-2 rounded-lg border px-3 py-2 text-xs"
title={[
startPoint ? formatRangeCoordinate(startPoint) : null,
endPoint ? formatRangeCoordinate(endPoint) : null,
]
.filter(Boolean)
.join(' to ')}
>
<MapPin className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 font-mono">
{startPoint
? formatCompactRangeCoordinate(startPoint)
: endPoint
? formatCompactRangeCoordinate(endPoint)
: null}
</span>
{startPoint && endPoint ? (
<>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 font-mono">
{formatCompactRangeCoordinate(endPoint)}
</span>
</>
) : null}
</div>
{hasIncompleteLocationRange ? (
<p className="text-xs text-destructive">
{startPoint
? 'Select an end location to complete the range.'
: 'Select a start location to complete the range.'}
</p>
) : null}
</div>
) : null}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="assign-due-date"> <Label htmlFor="assign-due-date">
Due date <span className="text-destructive">*</span> Due date <span className="text-destructive">*</span>
@@ -114,17 +175,61 @@ export function AssignTicketForm({
<Button <Button
type="button" type="button"
disabled={!selectedUserId || !dueDate || assignMutation.isPending} disabled={
!selectedUserId ||
!selectedContractor ||
!dueDate ||
hasIncompleteLocationRange ||
assignMutation.isPending
}
onClick={() => { onClick={() => {
if (!selectedUserId || !dueDate) return; if (
!selectedUserId ||
!selectedContractor ||
!dueDate ||
hasIncompleteLocationRange
) {
return;
}
const note = assignNote.trim() || undefined;
const hasCriteria =
selectedClassNames.length > 0 ||
Boolean(startPoint && endPoint);
assignMutation.mutate( assignMutation.mutate(
{ {
assigned_to_user_id: Number(selectedUserId), assigned_to_user_id: Number(selectedUserId),
assigned_to_email: selectedContractor.email,
due_at: dueDate.toISOString(), due_at: dueDate.toISOString(),
note: assignNote || undefined, note,
...(hasCriteria
? {
criteria: {
...(selectedClassNames.length > 0
? { class_names: selectedClassNames }
: {}),
...(startPoint && endPoint
? {
start_lat: startPoint.latitude,
start_lng: startPoint.longitude,
end_lat: endPoint.latitude,
end_lng: endPoint.longitude,
}
: {}),
},
}
: {}),
},
{
onSuccess: () => {
setSelectedUserId('');
setSelectedContractor(null);
setDueDate(undefined);
setAssignNote('');
onAssigned();
},
}, },
{ onSuccess: onAssigned },
); );
}} }}
className="w-full sm:w-auto" className="w-full sm:w-auto"

View File

@@ -19,12 +19,19 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import type { AssignmentRangePoint } from './assignmentRange';
import { AssignmentRangeActions } from './AssignmentRangeActions';
type AssignmentIssuesMapProps = { type AssignmentIssuesMapProps = {
data?: DetectionCoordinatesResponse; data?: DetectionCoordinatesResponse;
isLoading: boolean; isLoading: boolean;
isFetchingMore: boolean; isFetchingMore: boolean;
isError: boolean; isError: boolean;
hasMore: boolean; hasMore: boolean;
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
onSetStart: (point: AssignmentRangePoint) => void;
onSetEnd: (point: AssignmentRangePoint) => void;
onLoadMore: () => void; onLoadMore: () => void;
onRetry: () => void; onRetry: () => void;
}; };
@@ -33,7 +40,11 @@ type ClientAssignmentIssuesMapProps = {
items: DetectionCoordinateItem[]; items: DetectionCoordinateItem[];
classes: DetectionCoordinateClass[]; classes: DetectionCoordinateClass[];
selectedDetectionId: number | null; selectedDetectionId: number | null;
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
onSelectDetection: (detectionId: number) => void; onSelectDetection: (detectionId: number) => void;
onSetStart: (point: AssignmentRangePoint) => void;
onSetEnd: (point: AssignmentRangePoint) => void;
}; };
function isValidCoordinate(item: DetectionCoordinateItem) { function isValidCoordinate(item: DetectionCoordinateItem) {
@@ -49,8 +60,9 @@ function isValidCoordinate(item: DetectionCoordinateItem) {
const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>( const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
async () => { async () => {
const { CircleMarker, MapContainer, Popup, TileLayer, useMap } = const { CircleMarker, MapContainer, Popup, TileLayer, Tooltip, useMap } =
await import('react-leaflet'); await import('react-leaflet');
const { canvas } = await import('leaflet');
function FitMapToIssues({ items }: { items: DetectionCoordinateItem[] }) { function FitMapToIssues({ items }: { items: DetectionCoordinateItem[] }) {
const map = useMap(); const map = useMap();
@@ -81,8 +93,13 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
items, items,
classes, classes,
selectedDetectionId, selectedDetectionId,
startPoint,
endPoint,
onSelectDetection, onSelectDetection,
onSetStart,
onSetEnd,
}: ClientAssignmentIssuesMapProps) { }: ClientAssignmentIssuesMapProps) {
const markerRenderer = useMemo(() => canvas({ tolerance: 12 }), []);
const displayNames = new Map( const displayNames = new Map(
classes.map((item) => [item.class_name, item.display_name]), classes.map((item) => [item.class_name, item.display_name]),
); );
@@ -108,30 +125,48 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
const visual = getDefectVisual(item.class_name); const visual = getDefectVisual(item.class_name);
const IssueIcon = visual.icon; const IssueIcon = visual.icon;
const isSelected = selectedDetectionId === item.id; const isSelected = selectedDetectionId === item.id;
const isStart = startPoint?.detectionId === item.id;
const isEnd = endPoint?.detectionId === item.id;
const rangeLabel = isStart ? 'A' : isEnd ? 'B' : null;
const rangeColor = isStart
? '#16a34a'
: isEnd
? '#dc2626'
: visual.boundingBoxColor;
const isRangeIssue = isStart || isEnd;
const displayName = const displayName =
displayNames.get(item.class_name) ?? displayNames.get(item.class_name) ??
item.class_name.replaceAll('_', ' '); item.class_name.replaceAll('_', ' ');
const point: AssignmentRangePoint = {
detectionId: item.id,
latitude: item.latitude,
longitude: item.longitude,
};
return ( return (
<CircleMarker <CircleMarker
key={item.id} key={item.id}
center={[item.latitude, item.longitude]} center={[item.latitude, item.longitude]}
radius={isSelected ? 11 : 8} renderer={markerRenderer}
radius={isSelected || isRangeIssue ? 11 : 8}
pathOptions={{ pathOptions={{
color: isSelected ? '#ffffff' : visual.boundingBoxColor, color:
fillColor: visual.boundingBoxColor, isSelected || isRangeIssue
fillOpacity: isSelected ? 1 : 0.82, ? '#ffffff'
weight: isSelected ? 4 : 2, : visual.boundingBoxColor,
fillColor: rangeColor,
fillOpacity: isSelected || isRangeIssue ? 1 : 0.82,
weight: isSelected || isRangeIssue ? 4 : 2,
}} }}
eventHandlers={{ eventHandlers={{
click: () => onSelectDetection(item.id), click: () => onSelectDetection(item.id),
}} }}
> >
<Popup> <Popup minWidth={240} maxWidth={280}>
<div className="min-w-52 space-y-3"> <div className="w-60">
<div className="flex items-center gap-2"> <div className="flex items-start gap-3 border-b pb-3 pr-4">
<span <span
className="flex size-8 items-center justify-center rounded-full" className="flex size-9 shrink-0 items-center justify-center rounded-full"
style={{ style={{
backgroundColor: `${visual.boundingBoxColor}1f`, backgroundColor: `${visual.boundingBoxColor}1f`,
color: visual.boundingBoxColor, color: visual.boundingBoxColor,
@@ -139,32 +174,63 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
> >
<IssueIcon className="size-4" /> <IssueIcon className="size-4" />
</span> </span>
<div> <div className="min-w-0 flex-1">
<p className="font-semibold capitalize"> <p className="!m-0 truncate text-sm font-semibold leading-5">
{displayName} {displayName}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="!m-0 text-xs leading-4 text-muted-foreground">
Detection #{item.id} Detection #{item.id}
</p> </p>
</div> </div>
{rangeLabel ? (
<span
className="flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white"
style={{ backgroundColor: rangeColor }}
aria-label={isStart ? 'Start issue' : 'End issue'}
>
{rangeLabel}
</span>
) : null}
</div> </div>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm"> <dl className="!m-0 space-y-2 py-3 text-sm">
<dt className="text-muted-foreground">Confidence</dt> <div className="flex items-center justify-between gap-4">
<dd className="text-right font-medium"> <dt className="text-muted-foreground">Confidence</dt>
{Math.round(item.confidence * 100)}% <dd className="!m-0 font-medium tabular-nums">
</dd> {Math.round(item.confidence * 100)}%
<dt className="text-muted-foreground">Latitude</dt> </dd>
<dd className="text-right font-mono text-xs"> </div>
{item.latitude.toFixed(6)} <div className="flex items-center justify-between gap-4">
</dd> <dt className="text-muted-foreground">Latitude</dt>
<dt className="text-muted-foreground">Longitude</dt> <dd className="!m-0 font-mono text-xs tabular-nums">
<dd className="text-right font-mono text-xs"> {item.latitude.toFixed(6)}
{item.longitude.toFixed(6)} </dd>
</dd> </div>
<div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">Longitude</dt>
<dd className="!m-0 font-mono text-xs tabular-nums">
{item.longitude.toFixed(6)}
</dd>
</div>
</dl> </dl>
<div className="border-t pt-3">
<AssignmentRangeActions
point={point}
startPoint={startPoint}
endPoint={endPoint}
onSetStart={onSetStart}
onSetEnd={onSetEnd}
stretch
/>
</div>
</div> </div>
</Popup> </Popup>
{rangeLabel ? (
<Tooltip permanent direction="center" opacity={1}>
{rangeLabel}
</Tooltip>
) : null}
</CircleMarker> </CircleMarker>
); );
})} })}
@@ -184,6 +250,10 @@ export function AssignmentIssuesMap({
isFetchingMore, isFetchingMore,
isError, isError,
hasMore, hasMore,
startPoint,
endPoint,
onSetStart,
onSetEnd,
onLoadMore, onLoadMore,
onRetry, onRetry,
}: AssignmentIssuesMapProps) { }: AssignmentIssuesMapProps) {
@@ -217,7 +287,7 @@ export function AssignmentIssuesMap({
<div> <div>
<CardTitle className="text-base">Issues on Map</CardTitle> <CardTitle className="text-base">Issues on Map</CardTitle>
<CardDescription> <CardDescription>
Click a map point to view the issue details. Click a marker, then choose Start or End.
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@@ -261,7 +331,11 @@ export function AssignmentIssuesMap({
items={validItems} items={validItems}
classes={data?.classes ?? []} classes={data?.classes ?? []}
selectedDetectionId={selectedDetectionId} selectedDetectionId={selectedDetectionId}
startPoint={startPoint}
endPoint={endPoint}
onSelectDetection={setSelectedDetectionId} onSelectDetection={setSelectedDetectionId}
onSetStart={onSetStart}
onSetEnd={onSetEnd}
/> />
</div> </div>

View File

@@ -0,0 +1,66 @@
'use client';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { AssignmentRangePoint } from './assignmentRange';
interface AssignmentRangeActionsProps {
point: AssignmentRangePoint;
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
onSetStart: (point: AssignmentRangePoint) => void;
onSetEnd: (point: AssignmentRangePoint) => void;
stretch?: boolean;
className?: string;
}
export function AssignmentRangeActions({
point,
startPoint,
endPoint,
onSetStart,
onSetEnd,
stretch = false,
className,
}: AssignmentRangeActionsProps) {
const isStart = point.detectionId === startPoint?.detectionId;
const isEnd = point.detectionId === endPoint?.detectionId;
return (
<div className={cn('flex items-center gap-1', className)}>
<Button
type="button"
size="xs"
variant={isStart ? 'secondary' : 'outline'}
className={cn(stretch && 'flex-1')}
disabled={isStart}
aria-pressed={isStart}
title={isStart ? 'Selected as start' : 'Set as start'}
onClick={() => onSetStart(point)}
>
Start
</Button>
<Button
type="button"
size="xs"
variant={isEnd ? 'secondary' : 'outline'}
className={cn(stretch && 'flex-1')}
disabled={startPoint === null || isStart || isEnd}
aria-pressed={isEnd}
title={
startPoint === null
? 'Select a start issue first'
: isStart
? 'Choose another issue for the end'
: isEnd
? 'Selected as end'
: 'Set as end'
}
onClick={() => onSetEnd(point)}
>
End
</Button>
</div>
);
}

View File

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

View File

@@ -3,15 +3,32 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import type { DetectionResultItem } from '@/types'; import type { DetectionResultItem } from '@/types';
import type { AssignmentRangePoint } from './assignmentRange';
import { AssignmentRangeActions } from './AssignmentRangeActions';
function formatTimestamp(seconds: number) { function formatTimestamp(seconds: number) {
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60); const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
} }
export function useIssueColumns(): ColumnDef<DetectionResultItem>[] { interface UseIssueColumnsOptions {
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
onSetStart: (point: AssignmentRangePoint) => void;
onSetEnd: (point: AssignmentRangePoint) => void;
}
export function useIssueColumns({
startPoint,
endPoint,
onSetStart,
onSetEnd,
}: UseIssueColumnsOptions): ColumnDef<DetectionResultItem>[] {
return useMemo( return useMemo(
() => [ () => [
{ {
@@ -24,20 +41,53 @@ export function useIssueColumns(): ColumnDef<DetectionResultItem>[] {
id: 'issue_type', id: 'issue_type',
header: 'Issue Type', header: 'Issue Type',
size: 180, size: 180,
cell: ({ row }) => row.original.detection.display_name, cell: ({ row }) => {
const { class_name: className, display_name: displayName } =
row.original.detection;
const visual = getDefectVisual(className);
const IssueIcon = visual.icon;
return (
<Badge
variant="outline"
className="h-5 gap-1.5 px-2 font-normal"
style={{
borderColor: `${visual.boundingBoxColor}66`,
backgroundColor: `${visual.boundingBoxColor}14`,
color: visual.boundingBoxColor,
}}
>
<IssueIcon />
{displayName}
</Badge>
);
},
}, },
{ {
id: 'confidence', id: 'coordinates',
header: 'Confidence', header: 'Coordinates',
size: 130, size: 220,
cell: ({ row }) => cell: ({ row }) => {
`${Math.round(row.original.detection.confidence * 100)}%`, const issueId = row.original.detection.id;
}, const { latitude, longitude } = row.original.location;
{
id: 'frame', if (latitude === null || longitude === null) {
header: 'Frame', return <span className="text-muted-foreground">Unavailable</span>;
size: 100, }
cell: ({ row }) => row.original.frame.number,
return (
<div>
<p className="font-mono text-xs">
{latitude.toFixed(6)}, {longitude.toFixed(6)}
</p>
{issueId === startPoint?.detectionId ? (
<p className="text-xs font-medium text-green-600">Start</p>
) : issueId === endPoint?.detectionId ? (
<p className="text-xs font-medium text-red-600">End</p>
) : null}
</div>
);
},
}, },
{ {
id: 'timestamp', id: 'timestamp',
@@ -46,7 +96,44 @@ export function useIssueColumns(): ColumnDef<DetectionResultItem>[] {
cell: ({ row }) => cell: ({ row }) =>
formatTimestamp(row.original.frame.timestamp_seconds), formatTimestamp(row.original.frame.timestamp_seconds),
}, },
{
id: 'range_actions',
header: 'Set range',
size: 150,
cell: ({ row }) => {
const detectionId = row.original.detection.id;
const { latitude, longitude } = row.original.location;
const hasValidCoordinate =
latitude !== null &&
longitude !== null &&
Number.isFinite(latitude) &&
Number.isFinite(longitude) &&
latitude >= -90 &&
latitude <= 90 &&
longitude >= -180 &&
longitude <= 180;
if (!hasValidCoordinate) {
return <span className="text-muted-foreground">Unavailable</span>;
}
const point: AssignmentRangePoint = {
detectionId,
latitude,
longitude,
};
return (
<AssignmentRangeActions
point={point}
startPoint={startPoint}
endPoint={endPoint}
onSetStart={onSetStart}
onSetEnd={onSetEnd}
/>
);
},
},
], ],
[], [endPoint, onSetEnd, onSetStart, startPoint],
); );
} }

View File

@@ -33,7 +33,7 @@ export function IssueTable({
}: IssueTableProps) { }: IssueTableProps) {
return ( return (
<DataTable <DataTable
title="Choose Issues" title="Issue details"
columns={columns} columns={columns}
data={issues} data={issues}
isLoading={isLoading} isLoading={isLoading}

View File

@@ -0,0 +1,13 @@
export interface AssignmentRangePoint {
detectionId: number;
latitude: number;
longitude: number;
}
export function formatRangeCoordinate(point: AssignmentRangePoint) {
return `${point.latitude.toFixed(6)}, ${point.longitude.toFixed(6)}`;
}
export function formatCompactRangeCoordinate(point: AssignmentRangePoint) {
return `${point.latitude.toFixed(4)}, ${point.longitude.toFixed(4)}`;
}

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { AlertTriangle, ArrowLeft, LockKeyhole } from 'lucide-react'; import { AlertTriangle, ArrowLeft, LockKeyhole } from 'lucide-react';
@@ -11,6 +12,7 @@ import { PermissionGuard } from '@/guards';
import { ROUTES } from '@/utils/routes'; import { ROUTES } from '@/utils/routes';
import { useTicketOverviewQuery } from '../../hooks/useTicketQueries'; import { useTicketOverviewQuery } from '../../hooks/useTicketQueries';
import type { AssignmentRangePoint } from './components/assignmentRange';
import { AssignTicketForm } from './components/AssignTicketForm'; import { AssignTicketForm } from './components/AssignTicketForm';
import { ChooseIssuesSection } from './components/ChooseIssuesSection'; import { ChooseIssuesSection } from './components/ChooseIssuesSection';
@@ -35,6 +37,11 @@ function AssignmentPageSkeleton() {
export default function TicketAssignmentPage() { export default function TicketAssignmentPage() {
const router = useRouter(); const router = useRouter();
const { ticketId } = useParams() as { ticketId: string }; const { ticketId } = useParams() as { ticketId: string };
const [startPoint, setStartPoint] = useState<AssignmentRangePoint | null>(
null,
);
const [endPoint, setEndPoint] = useState<AssignmentRangePoint | null>(null);
const [selectedIssueTypes, setSelectedIssueTypes] = useState<string[]>([]);
const overviewQuery = useTicketOverviewQuery(ticketId); const overviewQuery = useTicketOverviewQuery(ticketId);
const overview = overviewQuery.data; const overview = overviewQuery.data;
const videoId = overview?.video_id ?? overview?.video?.id ?? undefined; const videoId = overview?.video_id ?? overview?.video?.id ?? undefined;
@@ -74,7 +81,7 @@ export default function TicketAssignmentPage() {
const ticketLabel = overview.ticket_name || overview.id; const ticketLabel = overview.ticket_name || overview.id;
return ( return (
<main className="relative z-10 space-y-5"> <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">
<header className="flex items-start gap-3"> <header className="flex items-start gap-3">
<Button <Button
type="button" type="button"
@@ -96,13 +103,21 @@ export default function TicketAssignmentPage() {
</div> </div>
</header> </header>
<div className="grid min-w-0 items-start gap-5 xl:grid-cols-[minmax(0,1fr)_360px]"> <div className="grid min-w-0 items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-stretch xl:overflow-hidden">
<ChooseIssuesSection <div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
videoId={videoId} <ChooseIssuesSection
issueTypes={overview.ai_result.detections_by_class} videoId={videoId}
/> issueTypes={overview.ai_result.detections_by_class}
selectedIssueTypes={selectedIssueTypes}
startPoint={startPoint}
endPoint={endPoint}
onSelectedIssueTypesChange={setSelectedIssueTypes}
onStartPointChange={setStartPoint}
onEndPointChange={setEndPoint}
/>
</div>
<aside className="min-w-0 xl:sticky xl:top-0"> <aside className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
<PermissionGuard <PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN} permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={ fallback={
@@ -129,7 +144,14 @@ export default function TicketAssignmentPage() {
> >
<AssignTicketForm <AssignTicketForm
ticketId={ticketId} ticketId={ticketId}
onAssigned={() => router.replace(ticketDetailUrl)} videoId={videoId}
selectedClassNames={selectedIssueTypes}
startPoint={startPoint}
endPoint={endPoint}
onAssigned={() => {
setStartPoint(null);
setEndPoint(null);
}}
/> />
</PermissionGuard> </PermissionGuard>
</aside> </aside>

View File

@@ -131,9 +131,10 @@ export function useDetectionCoordinatesQuery(
() => ({ () => ({
limit: params.limit ?? 50, limit: params.limit ?? 50,
class_name: params.class_name, class_name: params.class_name,
assignment_status: params.assignment_status,
search: params.search, search: params.search,
}), }),
[params.class_name, params.limit, params.search], [params.assignment_status, params.class_name, params.limit, params.search],
); );
return useInfiniteQuery({ return useInfiniteQuery({
@@ -320,7 +321,7 @@ function mergeTicketDetailResponse(
}; };
} }
export function useAssignTicketMutation(ticketId: string) { export function useAssignTicketMutation(ticketId: string, videoId?: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
@@ -330,9 +331,19 @@ export function useAssignTicketMutation(ticketId: string) {
toast.success('Ticket assigned'); toast.success('Ticket assigned');
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: [...ticketKeys.details(), ticketId], queryKey: ticketKeys.overview(ticketId),
}), }),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }), queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
...(videoId
? [
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetectionLists(videoId),
}),
queryClient.invalidateQueries({
queryKey: ticketKeys.detectionCoordinateLists(videoId),
}),
]
: []),
]); ]);
}, },
onError: () => toast.error('Failed to assign ticket'), onError: () => toast.error('Failed to assign ticket'),

View File

@@ -17,14 +17,15 @@ export const ticketKeys = {
[...ticketKeys.details(), 'video', videoId, 'detections'] as const, [...ticketKeys.details(), 'video', videoId, 'detections'] as const,
classDetections: (videoId: string, params: VideoDetectionsParams) => classDetections: (videoId: string, params: VideoDetectionsParams) =>
[...ticketKeys.classDetectionLists(videoId), params] as const, [...ticketKeys.classDetectionLists(videoId), params] as const,
detectionCoordinates: (videoId: string, params: DetectionCoordinatesParams) => detectionCoordinateLists: (videoId: string) =>
[ [
...ticketKeys.details(), ...ticketKeys.details(),
'video', 'video',
videoId, videoId,
'detection-coordinates', 'detection-coordinates',
params,
] as const, ] as const,
detectionCoordinates: (videoId: string, params: DetectionCoordinatesParams) =>
[...ticketKeys.detectionCoordinateLists(videoId), params] as const,
classReviewDetections: ( classReviewDetections: (
videoId: string, videoId: string,
defectClass: string, defectClass: string,

View File

@@ -27,6 +27,7 @@ interface MultiSelectPopoverProps {
onValuesChange: (values: string[]) => void; onValuesChange: (values: string[]) => void;
searchPlaceholder?: string; searchPlaceholder?: string;
emptyMessage?: string; emptyMessage?: string;
emptySelectionLabel?: string;
align?: 'start' | 'center' | 'end'; align?: 'start' | 'center' | 'end';
} }
@@ -45,6 +46,7 @@ export function MultiSelectPopover({
onValuesChange, onValuesChange,
searchPlaceholder = 'Search', searchPlaceholder = 'Search',
emptyMessage = 'No options found.', emptyMessage = 'No options found.',
emptySelectionLabel,
align = 'end', align = 'end',
}: MultiSelectPopoverProps) { }: MultiSelectPopoverProps) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -96,7 +98,11 @@ export function MultiSelectPopover({
> >
{icon} {icon}
{label} {label}
<Badge variant="secondary">{values.length}</Badge> <Badge variant="secondary">
{values.length === 0 && emptySelectionLabel
? emptySelectionLabel
: values.length}
</Badge>
<ChevronDown /> <ChevronDown />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>

View File

@@ -68,7 +68,7 @@ export const API_ROUTES = {
CLASS_DETAIL: (id: string, defectClass: string) => CLASS_DETAIL: (id: string, defectClass: string) =>
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`, `/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`, DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`, ASSIGNMENTS: (id: string) => `/biz/api/v1/tickets/${id}/assignments`,
REQUEST_EXTENSION: (ticketId: string, assignmentId: number) => REQUEST_EXTENSION: (ticketId: string, assignmentId: number) =>
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-requests`, `/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-requests`,
EXTENSION_REQUESTS: (ticketId: string) => EXTENSION_REQUESTS: (ticketId: string) =>

View File

@@ -26,6 +26,9 @@ export const detectionService = {
if (params?.limit !== undefined) { if (params?.limit !== undefined) {
searchParams.set('limit', String(params.limit)); searchParams.set('limit', String(params.limit));
} }
if (params?.assignment_status) {
searchParams.set('assignment_status', params.assignment_status);
}
if (params?.search) { if (params?.search) {
searchParams.set('search', params.search); searchParams.set('search', params.search);
} }

View File

@@ -82,7 +82,7 @@ export const ticketService = {
payload: AssignTicketPayload, payload: AssignTicketPayload,
): Promise<TicketDetail> => { ): Promise<TicketDetail> => {
const response = await axiosClient.post<TicketDetail>( const response = await axiosClient.post<TicketDetail>(
API_ROUTES.TICKETS.ASSIGN(ticketId), API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
payload, payload,
); );
return response.data; return response.data;

View File

@@ -19,10 +19,13 @@ export type DetectionCoordinateItem = {
confidence: number; confidence: number;
}; };
export type DetectionAssignmentStatus = 'assigned' | 'unassigned';
export type DetectionCoordinatesParams = { export type DetectionCoordinatesParams = {
skip?: number; skip?: number;
limit?: number; limit?: number;
class_name?: string[]; class_name?: string[];
assignment_status?: DetectionAssignmentStatus;
search?: string; search?: string;
}; };

View File

@@ -22,10 +22,20 @@ export interface AssignableTicketUsersResponse {
total: number; total: number;
} }
export interface TicketAssignmentCriteria {
class_names?: string[];
start_lat?: number;
start_lng?: number;
end_lat?: number;
end_lng?: number;
}
export interface AssignTicketPayload { export interface AssignTicketPayload {
assigned_to_user_id: number; assigned_to_user_id: number;
assigned_to_email: string;
due_at: string; due_at: string;
note?: string; note?: string;
criteria?: TicketAssignmentCriteria;
} }
export interface RequestTicketExtensionPayload { export interface RequestTicketExtensionPayload {