diff --git a/src/app/(modules)/ticket/[ticketId]/assign/components/AssignTicketForm.tsx b/src/app/(modules)/ticket/[ticketId]/assign/components/AssignTicketForm.tsx index d762852..9209a03 100644 --- a/src/app/(modules)/ticket/[ticketId]/assign/components/AssignTicketForm.tsx +++ b/src/app/(modules)/ticket/[ticketId]/assign/components/AssignTicketForm.tsx @@ -1,7 +1,15 @@ 'use client'; 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 { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect'; @@ -12,14 +20,27 @@ import { Textarea } from '@/components/ui/textarea'; import type { AssignableTicketUser } from '@/types'; import { useAssignTicketMutation } from '../../../hooks/useTicketQueries'; +import { + formatCompactRangeCoordinate, + formatRangeCoordinate, + type AssignmentRangePoint, +} from './assignmentRange'; interface AssignTicketFormProps { ticketId: string; + videoId?: string; + selectedClassNames: string[]; + startPoint: AssignmentRangePoint | null; + endPoint: AssignmentRangePoint | null; onAssigned: () => void; } export function AssignTicketForm({ ticketId, + videoId, + selectedClassNames, + startPoint, + endPoint, onAssigned, }: AssignTicketFormProps) { const [selectedUserId, setSelectedUserId] = useState(''); @@ -28,7 +49,7 @@ export function AssignTicketForm({ const [dueDate, setDueDate] = useState(); const [assignNote, setAssignNote] = useState(''); - const assignMutation = useAssignTicketMutation(ticketId); + const assignMutation = useAssignTicketMutation(ticketId, videoId); const today = useMemo(() => { const date = new Date(); date.setHours(0, 0, 0, 0); @@ -38,6 +59,7 @@ export function AssignTicketForm({ () => new Date(today.getFullYear() + 20, 11), [today], ); + const hasIncompleteLocationRange = Boolean(startPoint) !== Boolean(endPoint); return ( @@ -83,6 +105,45 @@ export function AssignTicketForm({ ) : null} + {startPoint || endPoint ? ( +
+ +
+ + + {startPoint + ? formatCompactRangeCoordinate(startPoint) + : endPoint + ? formatCompactRangeCoordinate(endPoint) + : null} + + {startPoint && endPoint ? ( + <> + + + {formatCompactRangeCoordinate(endPoint)} + + + ) : null} +
+ {hasIncompleteLocationRange ? ( +

+ {startPoint + ? 'Select an end location to complete the range.' + : 'Select a start location to complete the range.'} +

+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/src/app/(modules)/ticket/[ticketId]/assign/components/ChooseIssuesSection.tsx b/src/app/(modules)/ticket/[ticketId]/assign/components/ChooseIssuesSection.tsx index df12a53..8d09b2e 100644 --- a/src/app/(modules)/ticket/[ticketId]/assign/components/ChooseIssuesSection.tsx +++ b/src/app/(modules)/ticket/[ticketId]/assign/components/ChooseIssuesSection.tsx @@ -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([]); - 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 (
-
-
- -
+ + + Filters + + +
+ -
- - setDetectionSearch(event.target.value)} - placeholder="Search by detection ID" - aria-label="Search by detection ID" - className="pl-9" - /> -
-
+ {startPoint ? ( + + Start + + {formatCompactRangeCoordinate(startPoint)} + + + ) : null} + + {endPoint ? ( + + End + + {formatCompactRangeCoordinate(endPoint)} + + + ) : null} + + {startPoint !== null || endPoint !== null ? ( + + ) : null} +
+ + void coordinatesQuery.fetchNextPage()} onRetry={() => void coordinatesQuery.refetch()} /> @@ -136,7 +214,7 @@ export function ChooseIssuesSection({ [] { +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[] { return useMemo( () => [ { @@ -24,20 +41,53 @@ export function useIssueColumns(): ColumnDef[] { id: 'issue_type', header: 'Issue Type', 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 ( + + + {displayName} + + ); + }, }, { - 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: 'coordinates', + header: 'Coordinates', + size: 220, + cell: ({ row }) => { + const issueId = row.original.detection.id; + const { latitude, longitude } = row.original.location; + + if (latitude === null || longitude === null) { + return Unavailable; + } + + return ( +
+

+ {latitude.toFixed(6)}, {longitude.toFixed(6)} +

+ {issueId === startPoint?.detectionId ? ( +

Start

+ ) : issueId === endPoint?.detectionId ? ( +

End

+ ) : null} +
+ ); + }, }, { id: 'timestamp', @@ -46,7 +96,44 @@ export function useIssueColumns(): ColumnDef[] { cell: ({ row }) => 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 Unavailable; + } + + const point: AssignmentRangePoint = { + detectionId, + latitude, + longitude, + }; + return ( + + ); + }, + }, ], - [], + [endPoint, onSetEnd, onSetStart, startPoint], ); } diff --git a/src/app/(modules)/ticket/[ticketId]/assign/components/IssueTable.tsx b/src/app/(modules)/ticket/[ticketId]/assign/components/IssueTable.tsx index 43edc35..3920e01 100644 --- a/src/app/(modules)/ticket/[ticketId]/assign/components/IssueTable.tsx +++ b/src/app/(modules)/ticket/[ticketId]/assign/components/IssueTable.tsx @@ -33,7 +33,7 @@ export function IssueTable({ }: IssueTableProps) { return ( ( + null, + ); + const [endPoint, setEndPoint] = useState(null); + const [selectedIssueTypes, setSelectedIssueTypes] = useState([]); const overviewQuery = useTicketOverviewQuery(ticketId); const overview = overviewQuery.data; const videoId = overview?.video_id ?? overview?.video?.id ?? undefined; @@ -74,7 +81,7 @@ export default function TicketAssignmentPage() { const ticketLabel = overview.ticket_name || overview.id; return ( -
+
-
- +
+
+ +
-