diff --git a/src/app/(modules)/ticket/[ticketId]/assign/components/AssignmentIssuesMap.tsx b/src/app/(modules)/ticket/[ticketId]/assign/components/AssignmentIssuesMap.tsx new file mode 100644 index 0000000..e4d6581 --- /dev/null +++ b/src/app/(modules)/ticket/[ticketId]/assign/components/AssignmentIssuesMap.tsx @@ -0,0 +1,322 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import dynamic from 'next/dynamic'; +import { AlertTriangle, MapPinned, RefreshCw } from 'lucide-react'; + +import { getDefectVisual } from '@/constants/defectVisualConfig'; +import type { + DetectionCoordinateClass, + DetectionCoordinateItem, + DetectionCoordinatesResponse, +} from '@/types'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; + +type AssignmentIssuesMapProps = { + data?: DetectionCoordinatesResponse; + isLoading: boolean; + isFetchingMore: boolean; + isError: boolean; + hasMore: boolean; + onLoadMore: () => void; + onRetry: () => void; +}; + +type ClientAssignmentIssuesMapProps = { + items: DetectionCoordinateItem[]; + classes: DetectionCoordinateClass[]; + selectedDetectionId: number | null; + onSelectDetection: (detectionId: number) => void; +}; + +function isValidCoordinate(item: DetectionCoordinateItem) { + return ( + Number.isFinite(item.latitude) && + Number.isFinite(item.longitude) && + item.latitude >= -90 && + item.latitude <= 90 && + item.longitude >= -180 && + item.longitude <= 180 + ); +} + +const ClientAssignmentIssuesMap = dynamic( + async () => { + const { CircleMarker, MapContainer, Popup, TileLayer, useMap } = + await import('react-leaflet'); + + function FitMapToIssues({ items }: { items: DetectionCoordinateItem[] }) { + const map = useMap(); + + useEffect(() => { + if (items.length === 0) return; + + if (items.length === 1) { + map.setView([items[0].latitude, items[0].longitude], 17); + return; + } + + map.fitBounds( + items.map( + (item) => [item.latitude, item.longitude] as [number, number], + ), + { + padding: [32, 32], + maxZoom: 17, + }, + ); + }, [items, map]); + + return null; + } + + return function ClientAssignmentIssuesMapInner({ + items, + classes, + selectedDetectionId, + onSelectDetection, + }: ClientAssignmentIssuesMapProps) { + const displayNames = new Map( + classes.map((item) => [item.class_name, item.display_name]), + ); + const initialCenter: [number, number] = [ + items[0].latitude, + items[0].longitude, + ]; + + return ( + + + + + {items.map((item) => { + const visual = getDefectVisual(item.class_name); + const IssueIcon = visual.icon; + const isSelected = selectedDetectionId === item.id; + const displayName = + displayNames.get(item.class_name) ?? + item.class_name.replaceAll('_', ' '); + + return ( + onSelectDetection(item.id), + }} + > + +
+
+ + + +
+

+ {displayName} +

+

+ Detection #{item.id} +

+
+
+ +
+
Confidence
+
+ {Math.round(item.confidence * 100)}% +
+
Latitude
+
+ {item.latitude.toFixed(6)} +
+
Longitude
+
+ {item.longitude.toFixed(6)} +
+
+
+
+
+ ); + })} +
+ ); + }; + }, + { + ssr: false, + loading: () =>
, + }, +); + +export function AssignmentIssuesMap({ + data, + isLoading, + isFetchingMore, + isError, + hasMore, + onLoadMore, + onRetry, +}: AssignmentIssuesMapProps) { + const [selectedDetectionId, setSelectedDetectionId] = useState( + null, + ); + const validItems = useMemo( + () => (data?.items ?? []).filter(isValidCoordinate), + [data?.items], + ); + const total = data?.total ?? 0; + const loadedCount = Math.min(data?.items.length ?? 0, total); + + useEffect(() => { + if ( + selectedDetectionId !== null && + !validItems.some((item) => item.id === selectedDetectionId) + ) { + setSelectedDetectionId(null); + } + }, [selectedDetectionId, validItems]); + + return ( + + +
+
+
+ +
+
+ Issues on Map + + Click a map point to view the issue details. + +
+
+ {data ? ( + + {validItems.length} mapped + + ) : null} +
+
+ + + {isLoading ? ( +
+ ) : isError ? ( +
+ +
+

Unable to load issue locations.

+

+ Check the connection and try again. +

+
+ +
+ ) : validItems.length === 0 ? ( +
+ +

No issue locations available.

+

+ Detections without valid coordinates cannot be placed on the map. +

+
+ ) : ( + <> +
+ +
+ +
+ Map legend + {(data?.classes ?? []).map((item) => { + const visual = getDefectVisual(item.class_name); + const IssueIcon = visual.icon; + + return ( +
+ + + + {item.display_name} +
+ ); + })} +
+ +
+ + Showing {loadedCount} of {total} + + {hasMore ? ( + + ) : ( + + All issues are shown + + )} +
+ + )} + + + ); +} diff --git a/src/app/(modules)/ticket/[ticketId]/assign/components/ChooseIssuesSection.tsx b/src/app/(modules)/ticket/[ticketId]/assign/components/ChooseIssuesSection.tsx index a0e0a32..df12a53 100644 --- a/src/app/(modules)/ticket/[ticketId]/assign/components/ChooseIssuesSection.tsx +++ b/src/app/(modules)/ticket/[ticketId]/assign/components/ChooseIssuesSection.tsx @@ -7,11 +7,16 @@ import { MultiSelectPopover } from '@/components/form/MultiSelectPopover'; import { Input } from '@/components/ui/input'; import { useDebounce } from '@/hooks/useDebounce'; -import { useTicketDetectionsQuery } from '../../../hooks/useTicketQueries'; +import { + useDetectionCoordinatesQuery, + useTicketDetectionsQuery, +} from '../../../hooks/useTicketQueries'; +import { AssignmentIssuesMap } from './AssignmentIssuesMap'; import { useIssueColumns } from './IssueColumns'; import { IssueTable } from './IssueTable'; const DEFAULT_PAGE_SIZE = 10; +const MAP_PAGE_SIZE = 50; interface IssueTypeOption { class_name: string; @@ -59,44 +64,87 @@ export function ChooseIssuesSection({ [debouncedSearch, limit, selectedIssueTypes, skip], ); const detectionsQuery = useTicketDetectionsQuery(videoId, queryParams); + const mapQueryParams = useMemo( + () => ({ + limit: MAP_PAGE_SIZE, + class_name: + selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined, + search: debouncedSearch || undefined, + }), + [debouncedSearch, selectedIssueTypes], + ); + const coordinatesQuery = useDetectionCoordinatesQuery( + videoId, + mapQueryParams, + ); const result = detectionsQuery.data; + const mapData = useMemo(() => { + const pages = coordinatesQuery.data?.pages; + + if (!pages?.length) return undefined; + + const latestPage = pages[pages.length - 1]; + + return { + ...latestPage, + items: pages.flatMap((page) => page.items), + truncated: Boolean(coordinatesQuery.hasNextPage), + }; + }, [coordinatesQuery.data?.pages, coordinatesQuery.hasNextPage]); return ( - -
- -
-
- - setDetectionSearch(event.target.value)} - placeholder="Search by detection ID" - aria-label="Search by detection ID" - className="pl-9" - /> -
+
+
+
+
- } - skip={skip} - limit={limit} - total={result?.total ?? 0} - onPageChange={setSkip} - onLimitChange={setLimit} - /> + +
+ + setDetectionSearch(event.target.value)} + placeholder="Search by detection ID" + aria-label="Search by detection ID" + className="pl-9" + /> +
+
+ + void coordinatesQuery.fetchNextPage()} + onRetry={() => void coordinatesQuery.refetch()} + /> + + +
); } diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketDetailHeader.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketDetailHeader.tsx index 7b0f1da..5825408 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/TicketDetailHeader.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/TicketDetailHeader.tsx @@ -12,10 +12,8 @@ import { AssignTicketAction } from './actions/AssignTicketAction'; export function TicketDetailHeader({ ticket, - canAssign = false, }: { ticket: TicketOverviewDetail; - canAssign?: boolean; }) { const router = useRouter(); const ticketLabel = ticket.ticket_name || ticket.id; @@ -28,11 +26,9 @@ export function TicketDetailHeader({
- {canAssign ? ( - - - - ) : null} + + +