Compare commits
4 Commits
87fa04e1b5
...
0a4d9512cb
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a4d9512cb | |||
| faefd34cd4 | |||
| 514b42ea91 | |||
| 9cd7186357 |
@@ -5,11 +5,7 @@ import dynamic from 'next/dynamic';
|
|||||||
import { AlertTriangle, MapPinned, RefreshCw } from 'lucide-react';
|
import { AlertTriangle, MapPinned, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||||
import type {
|
import type { DetectionResultItem, VideoDetectionsResponse } from '@/types';
|
||||||
DetectionCoordinateClass,
|
|
||||||
DetectionCoordinateItem,
|
|
||||||
DetectionCoordinatesResponse,
|
|
||||||
} from '@/types';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -20,51 +16,80 @@ import {
|
|||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
import type { AssignmentRangePoint } from './assignmentRange';
|
import type { AssignmentRangePoint } from './assignmentRange';
|
||||||
import { AssignmentRangeActions } from './AssignmentRangeActions';
|
|
||||||
|
|
||||||
type AssignmentIssuesMapProps = {
|
type AssignmentIssuesMapProps = {
|
||||||
data?: DetectionCoordinatesResponse;
|
data?: VideoDetectionsResponse;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isFetchingMore: boolean;
|
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
hasMore: boolean;
|
|
||||||
startPoint: AssignmentRangePoint | null;
|
startPoint: AssignmentRangePoint | null;
|
||||||
endPoint: AssignmentRangePoint | null;
|
endPoint: AssignmentRangePoint | null;
|
||||||
onSetStart: (point: AssignmentRangePoint) => void;
|
|
||||||
onSetEnd: (point: AssignmentRangePoint) => void;
|
|
||||||
onLoadMore: () => void;
|
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AssignmentMapItem = {
|
||||||
|
id: number;
|
||||||
|
class_name: string;
|
||||||
|
display_name: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
confidence: number;
|
||||||
|
sequence: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AssignmentMapClass = {
|
||||||
|
class_name: string;
|
||||||
|
display_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ClientAssignmentIssuesMapProps = {
|
type ClientAssignmentIssuesMapProps = {
|
||||||
items: DetectionCoordinateItem[];
|
items: AssignmentMapItem[];
|
||||||
classes: DetectionCoordinateClass[];
|
classes: AssignmentMapClass[];
|
||||||
selectedDetectionId: number | null;
|
selectedDetectionId: number | null;
|
||||||
startPoint: AssignmentRangePoint | null;
|
startPoint: AssignmentRangePoint | null;
|
||||||
endPoint: 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 toMapItem(item: DetectionResultItem): AssignmentMapItem | null {
|
||||||
return (
|
const { latitude, longitude } = item.location;
|
||||||
Number.isFinite(item.latitude) &&
|
if (
|
||||||
Number.isFinite(item.longitude) &&
|
typeof latitude !== 'number' ||
|
||||||
item.latitude >= -90 &&
|
typeof longitude !== 'number' ||
|
||||||
item.latitude <= 90 &&
|
!Number.isFinite(latitude) ||
|
||||||
item.longitude >= -180 &&
|
!Number.isFinite(longitude) ||
|
||||||
item.longitude <= 180
|
latitude < -90 ||
|
||||||
);
|
latitude > 90 ||
|
||||||
|
longitude < -180 ||
|
||||||
|
longitude > 180
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.detection.id,
|
||||||
|
class_name: item.detection.class_name,
|
||||||
|
display_name: item.detection.display_name,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
confidence: item.detection.confidence,
|
||||||
|
sequence: item.frame.timestamp_seconds,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
||||||
async () => {
|
async () => {
|
||||||
const { CircleMarker, MapContainer, Popup, TileLayer, Tooltip, useMap } =
|
const {
|
||||||
await import('react-leaflet');
|
CircleMarker,
|
||||||
|
MapContainer,
|
||||||
|
Polyline,
|
||||||
|
Popup,
|
||||||
|
TileLayer,
|
||||||
|
Tooltip,
|
||||||
|
useMap,
|
||||||
|
} = await import('react-leaflet');
|
||||||
const { canvas } = await import('leaflet');
|
const { canvas } = await import('leaflet');
|
||||||
|
|
||||||
function FitMapToIssues({ items }: { items: DetectionCoordinateItem[] }) {
|
function FitMapToIssues({ items }: { items: AssignmentMapItem[] }) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,8 +121,6 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
|||||||
startPoint,
|
startPoint,
|
||||||
endPoint,
|
endPoint,
|
||||||
onSelectDetection,
|
onSelectDetection,
|
||||||
onSetStart,
|
|
||||||
onSetEnd,
|
|
||||||
}: ClientAssignmentIssuesMapProps) {
|
}: ClientAssignmentIssuesMapProps) {
|
||||||
const markerRenderer = useMemo(() => canvas({ tolerance: 12 }), []);
|
const markerRenderer = useMemo(() => canvas({ tolerance: 12 }), []);
|
||||||
const displayNames = new Map(
|
const displayNames = new Map(
|
||||||
@@ -107,6 +130,27 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
|||||||
items[0].latitude,
|
items[0].latitude,
|
||||||
items[0].longitude,
|
items[0].longitude,
|
||||||
];
|
];
|
||||||
|
const orderedItems = [...items].sort(
|
||||||
|
(first, second) =>
|
||||||
|
first.sequence - second.sequence || first.id - second.id,
|
||||||
|
);
|
||||||
|
const startIndex = orderedItems.findIndex(
|
||||||
|
(item) => item.id === startPoint?.detectionId,
|
||||||
|
);
|
||||||
|
const endIndex = orderedItems.findIndex(
|
||||||
|
(item) => item.id === endPoint?.detectionId,
|
||||||
|
);
|
||||||
|
const hasSelectedRange = startIndex >= 0 && endIndex >= 0;
|
||||||
|
const rangeItems = hasSelectedRange
|
||||||
|
? orderedItems.slice(
|
||||||
|
Math.min(startIndex, endIndex),
|
||||||
|
Math.max(startIndex, endIndex) + 1,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const rangeDetectionIds = new Set(rangeItems.map((item) => item.id));
|
||||||
|
const rangePath = rangeItems.map(
|
||||||
|
(item) => [item.latitude, item.longitude] as [number, number],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapContainer
|
<MapContainer
|
||||||
@@ -119,7 +163,30 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
|||||||
attribution="© OpenStreetMap contributors"
|
attribution="© OpenStreetMap contributors"
|
||||||
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
/>
|
/>
|
||||||
<FitMapToIssues items={items} />
|
<FitMapToIssues items={hasSelectedRange ? rangeItems : items} />
|
||||||
|
|
||||||
|
{rangePath.length >= 2 ? (
|
||||||
|
<>
|
||||||
|
<Polyline
|
||||||
|
positions={rangePath}
|
||||||
|
interactive={false}
|
||||||
|
pathOptions={{
|
||||||
|
color: '#3b82f6',
|
||||||
|
opacity: 0.18,
|
||||||
|
weight: 26,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Polyline
|
||||||
|
positions={rangePath}
|
||||||
|
interactive={false}
|
||||||
|
pathOptions={{
|
||||||
|
color: '#2563eb',
|
||||||
|
opacity: 0.9,
|
||||||
|
weight: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const visual = getDefectVisual(item.class_name);
|
const visual = getDefectVisual(item.class_name);
|
||||||
@@ -127,35 +194,37 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
|||||||
const isSelected = selectedDetectionId === item.id;
|
const isSelected = selectedDetectionId === item.id;
|
||||||
const isStart = startPoint?.detectionId === item.id;
|
const isStart = startPoint?.detectionId === item.id;
|
||||||
const isEnd = endPoint?.detectionId === item.id;
|
const isEnd = endPoint?.detectionId === item.id;
|
||||||
const rangeLabel = isStart ? 'A' : isEnd ? 'B' : null;
|
const rangeLabel = isStart ? 'Start' : isEnd ? 'End' : null;
|
||||||
const rangeColor = isStart
|
const rangeColor = isStart
|
||||||
? '#16a34a'
|
? '#16a34a'
|
||||||
: isEnd
|
: isEnd
|
||||||
? '#dc2626'
|
? '#dc2626'
|
||||||
: visual.boundingBoxColor;
|
: visual.boundingBoxColor;
|
||||||
const isRangeIssue = isStart || isEnd;
|
const isRangeIssue = isStart || isEnd;
|
||||||
|
const isInsideRange = rangeDetectionIds.has(item.id);
|
||||||
const displayName =
|
const displayName =
|
||||||
displayNames.get(item.class_name) ??
|
displayNames.get(item.class_name) ??
|
||||||
|
item.display_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]}
|
||||||
renderer={markerRenderer}
|
renderer={markerRenderer}
|
||||||
radius={isSelected || isRangeIssue ? 11 : 8}
|
radius={isSelected || isRangeIssue ? 11 : isInsideRange ? 9 : 7}
|
||||||
pathOptions={{
|
pathOptions={{
|
||||||
color:
|
color:
|
||||||
isSelected || isRangeIssue
|
isSelected || isRangeIssue
|
||||||
? '#ffffff'
|
? '#ffffff'
|
||||||
: visual.boundingBoxColor,
|
: visual.boundingBoxColor,
|
||||||
fillColor: rangeColor,
|
fillColor: rangeColor,
|
||||||
fillOpacity: isSelected || isRangeIssue ? 1 : 0.82,
|
fillOpacity:
|
||||||
|
isSelected || isRangeIssue
|
||||||
|
? 1
|
||||||
|
: hasSelectedRange && !isInsideRange
|
||||||
|
? 0.22
|
||||||
|
: 0.82,
|
||||||
|
opacity: hasSelectedRange && !isInsideRange ? 0.3 : 1,
|
||||||
weight: isSelected || isRangeIssue ? 4 : 2,
|
weight: isSelected || isRangeIssue ? 4 : 2,
|
||||||
}}
|
}}
|
||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
@@ -183,11 +252,11 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{rangeLabel ? (
|
{rangeLabel ? (
|
||||||
|
<span className="flex shrink-0 items-center gap-1.5 text-xs font-semibold">
|
||||||
<span
|
<span
|
||||||
className="flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white"
|
className="size-3 rounded-full"
|
||||||
style={{ backgroundColor: rangeColor }}
|
style={{ backgroundColor: rangeColor }}
|
||||||
aria-label={isStart ? 'Start issue' : 'End issue'}
|
/>
|
||||||
>
|
|
||||||
{rangeLabel}
|
{rangeLabel}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -213,22 +282,17 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</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 ? (
|
{rangeLabel ? (
|
||||||
<Tooltip permanent direction="center" opacity={1}>
|
<Tooltip
|
||||||
{rangeLabel}
|
permanent
|
||||||
|
direction="top"
|
||||||
|
offset={[0, -10]}
|
||||||
|
opacity={1}
|
||||||
|
className="range-point-label"
|
||||||
|
>
|
||||||
|
<span style={{ color: rangeColor }}>{rangeLabel}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
</CircleMarker>
|
</CircleMarker>
|
||||||
@@ -247,25 +311,38 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
|||||||
export function AssignmentIssuesMap({
|
export function AssignmentIssuesMap({
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetchingMore,
|
|
||||||
isError,
|
isError,
|
||||||
hasMore,
|
|
||||||
startPoint,
|
startPoint,
|
||||||
endPoint,
|
endPoint,
|
||||||
onSetStart,
|
|
||||||
onSetEnd,
|
|
||||||
onLoadMore,
|
|
||||||
onRetry,
|
onRetry,
|
||||||
}: AssignmentIssuesMapProps) {
|
}: AssignmentIssuesMapProps) {
|
||||||
const [selectedDetectionId, setSelectedDetectionId] = useState<number | null>(
|
const [selectedDetectionId, setSelectedDetectionId] = useState<number | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const validItems = useMemo(
|
const validItems = useMemo(
|
||||||
() => (data?.items ?? []).filter(isValidCoordinate),
|
() =>
|
||||||
|
(data?.items ?? []).flatMap((item) => {
|
||||||
|
const mapItem = toMapItem(item);
|
||||||
|
return mapItem ? [mapItem] : [];
|
||||||
|
}),
|
||||||
[data?.items],
|
[data?.items],
|
||||||
);
|
);
|
||||||
const total = data?.total ?? 0;
|
const classes = useMemo(
|
||||||
const loadedCount = Math.min(data?.items.length ?? 0, total);
|
() =>
|
||||||
|
Array.from(
|
||||||
|
new Map(
|
||||||
|
validItems.map((item) => [
|
||||||
|
item.class_name,
|
||||||
|
{
|
||||||
|
class_name: item.class_name,
|
||||||
|
display_name: item.display_name,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).values(),
|
||||||
|
),
|
||||||
|
[validItems],
|
||||||
|
);
|
||||||
|
const pageItemCount = data?.items.length ?? 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -287,7 +364,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 marker, then choose Start or End.
|
Select Start and End in the table to preview the covered range.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,13 +406,11 @@ export function AssignmentIssuesMap({
|
|||||||
<div className="h-[440px] min-w-0 bg-muted">
|
<div className="h-[440px] min-w-0 bg-muted">
|
||||||
<ClientAssignmentIssuesMap
|
<ClientAssignmentIssuesMap
|
||||||
items={validItems}
|
items={validItems}
|
||||||
classes={data?.classes ?? []}
|
classes={classes}
|
||||||
selectedDetectionId={selectedDetectionId}
|
selectedDetectionId={selectedDetectionId}
|
||||||
startPoint={startPoint}
|
startPoint={startPoint}
|
||||||
endPoint={endPoint}
|
endPoint={endPoint}
|
||||||
onSelectDetection={setSelectedDetectionId}
|
onSelectDetection={setSelectedDetectionId}
|
||||||
onSetStart={onSetStart}
|
|
||||||
onSetEnd={onSetEnd}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -344,7 +419,7 @@ export function AssignmentIssuesMap({
|
|||||||
aria-label="Map legend"
|
aria-label="Map legend"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium">Map legend</span>
|
<span className="text-sm font-medium">Map legend</span>
|
||||||
{(data?.classes ?? []).map((item) => {
|
{classes.map((item) => {
|
||||||
const visual = getDefectVisual(item.class_name);
|
const visual = getDefectVisual(item.class_name);
|
||||||
const IssueIcon = visual.icon;
|
const IssueIcon = visual.icon;
|
||||||
|
|
||||||
@@ -366,27 +441,33 @@ export function AssignmentIssuesMap({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{startPoint ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span className="size-4 rounded-full border-2 border-white bg-green-600 shadow-sm" />
|
||||||
|
<span>Start</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{endPoint ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span className="size-4 rounded-full border-2 border-white bg-red-600 shadow-sm" />
|
||||||
|
<span>End</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{startPoint && endPoint ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span className="relative h-4 w-8 rounded-full bg-blue-500/20">
|
||||||
|
<span className="absolute top-1/2 right-0 left-0 h-0.5 -translate-y-1/2 bg-blue-600" />
|
||||||
|
</span>
|
||||||
|
<span>Coverage area</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3 border-t px-4 py-3">
|
<div className="border-t px-4 py-3">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Showing {loadedCount} of {total}
|
{validItems.length} mapped from {pageItemCount} cached{' '}
|
||||||
|
{pageItemCount === 1 ? 'issue' : 'issues'}
|
||||||
</span>
|
</span>
|
||||||
{hasMore ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onLoadMore}
|
|
||||||
disabled={isFetchingMore}
|
|
||||||
>
|
|
||||||
{isFetchingMore ? 'Loading...' : 'Load more issues'}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
All issues are shown
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { FilterX } from 'lucide-react';
|
||||||
|
|
||||||
import { MultiSelectPopover } from '@/components/form/MultiSelectPopover';
|
import { MultiSelectPopover } from '@/components/form/MultiSelectPopover';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import type { VideoDetectionsResponse } from '@/types';
|
||||||
|
|
||||||
import {
|
import { useTicketDetectionsQuery } from '../../../hooks/useTicketQueries';
|
||||||
useDetectionCoordinatesQuery,
|
|
||||||
useTicketDetectionsQuery,
|
|
||||||
} from '../../../hooks/useTicketQueries';
|
|
||||||
import {
|
import {
|
||||||
formatCompactRangeCoordinate,
|
formatCompactRangeCoordinate,
|
||||||
formatRangeCoordinate,
|
formatRangeCoordinate,
|
||||||
@@ -22,7 +20,6 @@ import { useIssueColumns } from './IssueColumns';
|
|||||||
import { IssueTable } from './IssueTable';
|
import { IssueTable } from './IssueTable';
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 10;
|
const DEFAULT_PAGE_SIZE = 10;
|
||||||
const MAP_PAGE_SIZE = 50;
|
|
||||||
|
|
||||||
interface IssueTypeOption {
|
interface IssueTypeOption {
|
||||||
class_name: string;
|
class_name: string;
|
||||||
@@ -34,6 +31,7 @@ interface ChooseIssuesSectionProps {
|
|||||||
videoId?: string;
|
videoId?: string;
|
||||||
issueTypes: IssueTypeOption[];
|
issueTypes: IssueTypeOption[];
|
||||||
selectedIssueTypes: string[];
|
selectedIssueTypes: string[];
|
||||||
|
cacheRevision: number;
|
||||||
startPoint: AssignmentRangePoint | null;
|
startPoint: AssignmentRangePoint | null;
|
||||||
endPoint: AssignmentRangePoint | null;
|
endPoint: AssignmentRangePoint | null;
|
||||||
onSelectedIssueTypesChange: (values: string[]) => void;
|
onSelectedIssueTypesChange: (values: string[]) => void;
|
||||||
@@ -45,6 +43,7 @@ export function ChooseIssuesSection({
|
|||||||
videoId,
|
videoId,
|
||||||
issueTypes,
|
issueTypes,
|
||||||
selectedIssueTypes,
|
selectedIssueTypes,
|
||||||
|
cacheRevision,
|
||||||
startPoint,
|
startPoint,
|
||||||
endPoint,
|
endPoint,
|
||||||
onSelectedIssueTypesChange,
|
onSelectedIssueTypesChange,
|
||||||
@@ -84,15 +83,21 @@ export function ChooseIssuesSection({
|
|||||||
);
|
);
|
||||||
const handleIssueTypesChange = useCallback(
|
const handleIssueTypesChange = useCallback(
|
||||||
(values: string[]) => {
|
(values: string[]) => {
|
||||||
|
setSkip(0);
|
||||||
onSelectedIssueTypesChange(values);
|
onSelectedIssueTypesChange(values);
|
||||||
onStartPointChange(null);
|
onStartPointChange(null);
|
||||||
onEndPointChange(null);
|
onEndPointChange(null);
|
||||||
},
|
},
|
||||||
[onEndPointChange, onSelectedIssueTypesChange, onStartPointChange],
|
[onEndPointChange, onSelectedIssueTypesChange, onStartPointChange],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
const hasActiveFilters =
|
||||||
|
selectedIssueTypes.length > 0 || startPoint !== null || endPoint !== null;
|
||||||
|
const clearFilters = useCallback(() => {
|
||||||
setSkip(0);
|
setSkip(0);
|
||||||
}, [selectedIssueTypes]);
|
onSelectedIssueTypesChange([]);
|
||||||
|
onStartPointChange(null);
|
||||||
|
onEndPointChange(null);
|
||||||
|
}, [onEndPointChange, onSelectedIssueTypesChange, onStartPointChange]);
|
||||||
|
|
||||||
const queryParams = useMemo(
|
const queryParams = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -105,39 +110,56 @@ export function ChooseIssuesSection({
|
|||||||
[limit, selectedIssueTypes, skip],
|
[limit, selectedIssueTypes, skip],
|
||||||
);
|
);
|
||||||
const detectionsQuery = useTicketDetectionsQuery(videoId, queryParams);
|
const detectionsQuery = useTicketDetectionsQuery(videoId, queryParams);
|
||||||
const mapQueryParams = useMemo(
|
|
||||||
() => ({
|
|
||||||
limit: MAP_PAGE_SIZE,
|
|
||||||
class_name:
|
|
||||||
selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined,
|
|
||||||
assignment_status: 'unassigned' as const,
|
|
||||||
}),
|
|
||||||
[selectedIssueTypes],
|
|
||||||
);
|
|
||||||
const coordinatesQuery = useDetectionCoordinatesQuery(
|
|
||||||
videoId,
|
|
||||||
mapQueryParams,
|
|
||||||
);
|
|
||||||
const result = detectionsQuery.data;
|
const result = detectionsQuery.data;
|
||||||
const mapData = useMemo(() => {
|
const mapCacheKey = useMemo(
|
||||||
const pages = coordinatesQuery.data?.pages;
|
() => JSON.stringify([videoId ?? '', selectedIssueTypes, cacheRevision]),
|
||||||
|
[cacheRevision, selectedIssueTypes, videoId],
|
||||||
|
);
|
||||||
|
const [mapCache, setMapCache] = useState<
|
||||||
|
Record<string, VideoDetectionsResponse>
|
||||||
|
>({});
|
||||||
|
|
||||||
if (!pages?.length) return undefined;
|
useEffect(() => {
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
const latestPage = pages[pages.length - 1];
|
setMapCache((current) => {
|
||||||
|
const itemsById = new Map(
|
||||||
|
(current[mapCacheKey]?.items ?? []).map((item) => [
|
||||||
|
item.detection.id,
|
||||||
|
item,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
result.items.forEach((item) => itemsById.set(item.detection.id, item));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...latestPage,
|
...current,
|
||||||
items: pages.flatMap((page) => page.items),
|
[mapCacheKey]: {
|
||||||
truncated: Boolean(coordinatesQuery.hasNextPage),
|
...result,
|
||||||
|
skip: 0,
|
||||||
|
limit: itemsById.size,
|
||||||
|
items: Array.from(itemsById.values()),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}, [coordinatesQuery.data?.pages, coordinatesQuery.hasNextPage]);
|
});
|
||||||
|
}, [mapCacheKey, result]);
|
||||||
|
|
||||||
|
const mapData = mapCache[mapCacheKey];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 space-y-5">
|
<div className="min-w-0 space-y-5">
|
||||||
<Card size="sm" aria-label="Issue filters">
|
<Card size="sm" aria-label="Issue filters">
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||||
<CardTitle>Filters</CardTitle>
|
<CardTitle>Filters</CardTitle>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasActiveFilters}
|
||||||
|
onClick={clearFilters}
|
||||||
|
>
|
||||||
|
<FilterX />
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -177,40 +199,10 @@ export function ChooseIssuesSection({
|
|||||||
</span>
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<AssignmentIssuesMap
|
|
||||||
data={mapData}
|
|
||||||
isLoading={coordinatesQuery.isLoading}
|
|
||||||
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()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<IssueTable
|
<IssueTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
issues={result?.items ?? []}
|
issues={result?.items ?? []}
|
||||||
@@ -223,6 +215,15 @@ export function ChooseIssuesSection({
|
|||||||
onPageChange={setSkip}
|
onPageChange={setSkip}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={setLimit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AssignmentIssuesMap
|
||||||
|
data={mapData}
|
||||||
|
isLoading={detectionsQuery.isLoading && !mapData}
|
||||||
|
isError={detectionsQuery.isError && !mapData}
|
||||||
|
startPoint={startPoint}
|
||||||
|
endPoint={endPoint}
|
||||||
|
onRetry={() => void detectionsQuery.refetch()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export default function TicketAssignmentPage() {
|
|||||||
);
|
);
|
||||||
const [endPoint, setEndPoint] = useState<AssignmentRangePoint | null>(null);
|
const [endPoint, setEndPoint] = useState<AssignmentRangePoint | null>(null);
|
||||||
const [selectedIssueTypes, setSelectedIssueTypes] = useState<string[]>([]);
|
const [selectedIssueTypes, setSelectedIssueTypes] = useState<string[]>([]);
|
||||||
|
const [assignmentRevision, setAssignmentRevision] = useState(0);
|
||||||
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;
|
||||||
@@ -109,6 +110,7 @@ export default function TicketAssignmentPage() {
|
|||||||
videoId={videoId}
|
videoId={videoId}
|
||||||
issueTypes={overview.ai_result.detections_by_class}
|
issueTypes={overview.ai_result.detections_by_class}
|
||||||
selectedIssueTypes={selectedIssueTypes}
|
selectedIssueTypes={selectedIssueTypes}
|
||||||
|
cacheRevision={assignmentRevision}
|
||||||
startPoint={startPoint}
|
startPoint={startPoint}
|
||||||
endPoint={endPoint}
|
endPoint={endPoint}
|
||||||
onSelectedIssueTypesChange={setSelectedIssueTypes}
|
onSelectedIssueTypesChange={setSelectedIssueTypes}
|
||||||
@@ -151,6 +153,7 @@ export default function TicketAssignmentPage() {
|
|||||||
onAssigned={() => {
|
onAssigned={() => {
|
||||||
setStartPoint(null);
|
setStartPoint(null);
|
||||||
setEndPoint(null);
|
setEndPoint(null);
|
||||||
|
setAssignmentRevision((revision) => revision + 1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import { PermissionGuard } from '@/guards';
|
import { PermissionGuard } from '@/guards';
|
||||||
import type { TicketAssignmentSummary } from '@/types';
|
import type { TicketAssignmentSummary } from '@/types';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
import { NoTicketAction } from './actions/NoTicketAction';
|
import { NoTicketAction } from './actions/NoTicketAction';
|
||||||
import { RequestExtensionAction } from './actions/RequestExtensionAction';
|
import { RequestExtensionAction } from './actions/RequestExtensionAction';
|
||||||
@@ -13,6 +16,29 @@ interface TicketStatusActionsProps {
|
|||||||
assignment?: TicketAssignmentSummary;
|
assignment?: TicketAssignmentSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OverdueLotAlert({
|
||||||
|
lotId,
|
||||||
|
dueAt,
|
||||||
|
}: {
|
||||||
|
lotId: number;
|
||||||
|
dueAt: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-destructive"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="mt-0.5 size-5 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold">Lot {lotId} is overdue</p>
|
||||||
|
<p className="mt-0.5 text-xs text-destructive/80">
|
||||||
|
The due date expired on {formatDate(dueAt)}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getTicketAction(
|
function getTicketAction(
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
assignment?: TicketAssignmentSummary,
|
assignment?: TicketAssignmentSummary,
|
||||||
@@ -51,5 +77,12 @@ export function TicketStatusActions({
|
|||||||
}: TicketStatusActionsProps) {
|
}: TicketStatusActionsProps) {
|
||||||
const action = getTicketAction(ticketId, assignment);
|
const action = getTicketAction(ticketId, assignment);
|
||||||
|
|
||||||
return action ? <div className="space-y-3">{action}</div> : null;
|
return action ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{assignment?.is_overdue ? (
|
||||||
|
<OverdueLotAlert lotId={assignment.id} dueAt={assignment.due_at} />
|
||||||
|
) : null}
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ export function RequestExtensionAction({
|
|||||||
dueAt: string | null;
|
dueAt: string | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<TicketActionCard icon={Clock3} title="Request Extension">
|
<TicketActionCard
|
||||||
|
icon={Clock3}
|
||||||
|
title="Request Extension"
|
||||||
|
contextLabel={`Lot ${assignmentId}`}
|
||||||
|
>
|
||||||
<TicketExtensionRequestPanel
|
<TicketExtensionRequestPanel
|
||||||
ticketId={ticketId}
|
ticketId={ticketId}
|
||||||
assignmentId={assignmentId}
|
assignmentId={assignmentId}
|
||||||
|
|||||||
@@ -105,7 +105,11 @@ function ExtensionReviewForm({
|
|||||||
const extensionDays = getExtensionDays(request);
|
const extensionDays = getExtensionDays(request);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TicketActionCard icon={CalendarClock} title="Extension Request Details">
|
<TicketActionCard
|
||||||
|
icon={CalendarClock}
|
||||||
|
title="Extension Request Details"
|
||||||
|
contextLabel={`Lot ${assignmentId}`}
|
||||||
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Requester request={request} />
|
<Requester request={request} />
|
||||||
|
|
||||||
@@ -253,7 +257,11 @@ export function ReviewExtensionRequestAction({
|
|||||||
|
|
||||||
if (extensionRequestQuery.isLoading) {
|
if (extensionRequestQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<TicketActionCard icon={Clock3} title="Extension Request">
|
<TicketActionCard
|
||||||
|
icon={Clock3}
|
||||||
|
title="Extension Request"
|
||||||
|
contextLabel={`Lot ${assignmentId}`}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
<span className="text-sm">Checking for requests...</span>
|
<span className="text-sm">Checking for requests...</span>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ interface TicketActionCardProps {
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
iconClassName?: string;
|
iconClassName?: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
contextLabel?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,15 +18,23 @@ export function TicketActionCard({
|
|||||||
icon: Icon,
|
icon: Icon,
|
||||||
iconClassName,
|
iconClassName,
|
||||||
title,
|
title,
|
||||||
|
contextLabel,
|
||||||
children,
|
children,
|
||||||
}: TicketActionCardProps) {
|
}: TicketActionCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Icon className={cn('text-primary', iconClassName)} />
|
<CardTitle className="flex min-w-0 items-center gap-2 text-base">
|
||||||
{title}
|
<Icon className={cn('shrink-0 text-primary', iconClassName)} />
|
||||||
|
<span className="truncate">{title}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
{contextLabel ? (
|
||||||
|
<Badge variant="secondary" className="shrink-0">
|
||||||
|
{contextLabel}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>{children}</CardContent>
|
<CardContent>{children}</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ export function useTicketDetailEvents(
|
|||||||
queryClient.refetchQueries({
|
queryClient.refetchQueries({
|
||||||
queryKey: ticketKeys.timeline(ticketId),
|
queryKey: ticketKeys.timeline(ticketId),
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ticketKeys.defectClasses(ticketId),
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
}, [queryClient, ticketId]);
|
}, [queryClient, ticketId]);
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { detectionService, ticketService, videoService } from '@/services/api';
|
|||||||
import type {
|
import type {
|
||||||
AssignTicketPayload,
|
AssignTicketPayload,
|
||||||
CloseTicketPayload,
|
CloseTicketPayload,
|
||||||
DetectionCoordinatesParams,
|
|
||||||
DiscardDetectionPayload,
|
DiscardDetectionPayload,
|
||||||
RequestTicketExtensionPayload,
|
RequestTicketExtensionPayload,
|
||||||
ReviewDetectionRepairProofPayload,
|
ReviewDetectionRepairProofPayload,
|
||||||
@@ -77,14 +76,6 @@ export function useTicketTimelineQuery(ticketId: string | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTicketDefectClassesQuery(ticketId: string | undefined) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ticketKeys.defectClasses(ticketId ?? ''),
|
|
||||||
queryFn: () => ticketService.getTicketDefectClasses(ticketId as string),
|
|
||||||
enabled: Boolean(ticketId),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTicketAssignmentsQuery(
|
export function useTicketAssignmentsQuery(
|
||||||
ticketId: string | undefined,
|
ticketId: string | undefined,
|
||||||
initialAssignments?: TicketAssignmentSummary[],
|
initialAssignments?: TicketAssignmentSummary[],
|
||||||
@@ -134,43 +125,7 @@ export function useTicketDetectionsQuery(
|
|||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
videoService.getVideoDetections(videoId as string, queryParams),
|
videoService.getVideoDetections(videoId as string, queryParams),
|
||||||
enabled: Boolean(videoId),
|
enabled: Boolean(videoId),
|
||||||
});
|
staleTime: Infinity,
|
||||||
}
|
|
||||||
|
|
||||||
export function useDetectionCoordinatesQuery(
|
|
||||||
videoId: string | undefined,
|
|
||||||
params: DetectionCoordinatesParams,
|
|
||||||
) {
|
|
||||||
const queryParams = useMemo<DetectionCoordinatesParams>(
|
|
||||||
() => ({
|
|
||||||
limit: params.limit ?? 50,
|
|
||||||
class_name: params.class_name,
|
|
||||||
assignment_status: params.assignment_status,
|
|
||||||
search: params.search,
|
|
||||||
}),
|
|
||||||
[params.assignment_status, params.class_name, params.limit, params.search],
|
|
||||||
);
|
|
||||||
|
|
||||||
return useInfiniteQuery({
|
|
||||||
queryKey: ticketKeys.detectionCoordinates(videoId ?? '', queryParams),
|
|
||||||
queryFn: ({ pageParam }) =>
|
|
||||||
detectionService.getCoordinates(videoId as string, {
|
|
||||||
...queryParams,
|
|
||||||
skip: pageParam,
|
|
||||||
}),
|
|
||||||
initialPageParam: 0,
|
|
||||||
getNextPageParam: (lastPage, allPages) => {
|
|
||||||
if (lastPage.items.length === 0) return undefined;
|
|
||||||
|
|
||||||
const loadedCount = allPages.reduce(
|
|
||||||
(total, page) => total + page.items.length,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
return loadedCount < lastPage.total ? loadedCount : undefined;
|
|
||||||
},
|
|
||||||
enabled: Boolean(videoId),
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,9 +186,6 @@ export function useDiscardDetectionMutation(
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ticketKeys.overview(ticketId),
|
queryKey: ticketKeys.overview(ticketId),
|
||||||
}),
|
}),
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ticketKeys.defectClasses(ticketId),
|
|
||||||
}),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ticketKeys.assignments(ticketId),
|
queryKey: ticketKeys.assignments(ticketId),
|
||||||
@@ -274,9 +226,6 @@ export function useReviewDetectionRepairProofMutation({
|
|||||||
queryKey: ticketKeys.classDetectionLists(videoId),
|
queryKey: ticketKeys.classDetectionLists(videoId),
|
||||||
})
|
})
|
||||||
: Promise.resolve(),
|
: Promise.resolve(),
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ticketKeys.defectClasses(ticketId),
|
|
||||||
}),
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ticketKeys.overview(ticketId),
|
queryKey: ticketKeys.overview(ticketId),
|
||||||
}),
|
}),
|
||||||
@@ -334,9 +283,6 @@ export function useAssignTicketMutation(ticketId: string, videoId?: string) {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ticketKeys.classDetectionLists(videoId),
|
queryKey: ticketKeys.classDetectionLists(videoId),
|
||||||
}),
|
}),
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ticketKeys.detectionCoordinateLists(videoId),
|
|
||||||
}),
|
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import type {
|
import type { TicketListParams, VideoDetectionsParams } from '@/types';
|
||||||
DetectionCoordinatesParams,
|
|
||||||
TicketListParams,
|
|
||||||
VideoDetectionsParams,
|
|
||||||
} from '@/types';
|
|
||||||
|
|
||||||
export const ticketKeys = {
|
export const ticketKeys = {
|
||||||
all: ['tickets'] as const,
|
all: ['tickets'] as const,
|
||||||
@@ -17,23 +13,12 @@ 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,
|
||||||
detectionCoordinateLists: (videoId: string) =>
|
|
||||||
[
|
|
||||||
...ticketKeys.details(),
|
|
||||||
'video',
|
|
||||||
videoId,
|
|
||||||
'detection-coordinates',
|
|
||||||
] as const,
|
|
||||||
detectionCoordinates: (videoId: string, params: DetectionCoordinatesParams) =>
|
|
||||||
[...ticketKeys.detectionCoordinateLists(videoId), params] as const,
|
|
||||||
reviewDetections: (videoId: string, proofStatus?: string) =>
|
reviewDetections: (videoId: string, proofStatus?: string) =>
|
||||||
[
|
[
|
||||||
...ticketKeys.classDetectionLists(videoId),
|
...ticketKeys.classDetectionLists(videoId),
|
||||||
'review',
|
'review',
|
||||||
proofStatus ?? 'all',
|
proofStatus ?? 'all',
|
||||||
] as const,
|
] as const,
|
||||||
defectClasses: (ticketId: string) =>
|
|
||||||
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
|
|
||||||
assignments: (ticketId: string) =>
|
assignments: (ticketId: string) =>
|
||||||
[...ticketKeys.details(), ticketId, 'assignments'] as const,
|
[...ticketKeys.details(), ticketId, 'assignments'] as const,
|
||||||
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
|
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
@import './typography.css';
|
@import './typography.css';
|
||||||
@import "tw-animate-css";
|
@import 'tw-animate-css';
|
||||||
@import "shadcn/tailwind.css";
|
@import 'shadcn/tailwind.css';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@@ -152,7 +152,8 @@
|
|||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
button:not(:disabled), [role="button"]:not(:disabled) {
|
button:not(:disabled),
|
||||||
|
[role='button']:not(:disabled) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,3 +166,18 @@
|
|||||||
.vds-slider-preview:has(.vds-slider-thumbnail img[src^='blob:']) {
|
.vds-slider-preview:has(.vds-slider-thumbnail img[src^='blob:']) {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leaflet-tooltip.range-point-label {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
text-shadow:
|
||||||
|
0 1px 2px rgb(255 255 255 / 95%),
|
||||||
|
0 0 4px rgb(255 255 255 / 95%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-tooltip.range-point-label::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ export const API_ROUTES = {
|
|||||||
ANNOTATION_FRAMES: (id: string) =>
|
ANNOTATION_FRAMES: (id: string) =>
|
||||||
`/biz/api/v1/results/${id}/annotation-frames`,
|
`/biz/api/v1/results/${id}/annotation-frames`,
|
||||||
DETECTIONS: (id: string) => `/biz/api/v1/results/${id}/detections`,
|
DETECTIONS: (id: string) => `/biz/api/v1/results/${id}/detections`,
|
||||||
COORDINATES: (id: string) => `/biz/api/v1/results/${id}/coordinates`,
|
|
||||||
},
|
},
|
||||||
DETECTIONS: {
|
DETECTIONS: {
|
||||||
DISCARD: (id: number) => `/biz/api/v1/detections/${id}/discard`,
|
DISCARD: (id: number) => `/biz/api/v1/detections/${id}/discard`,
|
||||||
@@ -66,7 +65,6 @@ export const API_ROUTES = {
|
|||||||
BASE: '/biz/api/v1/tickets',
|
BASE: '/biz/api/v1/tickets',
|
||||||
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
|
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
|
||||||
TIMELINE: (id: string) => `/biz/api/v1/tickets/${id}/timeline`,
|
TIMELINE: (id: string) => `/biz/api/v1/tickets/${id}/timeline`,
|
||||||
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,
|
|
||||||
ASSIGNMENTS: (id: string) => `/biz/api/v1/tickets/${id}/assignments`,
|
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`,
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import type { TicketDefectClassTicketStatus } from '@/types';
|
|
||||||
|
|
||||||
export const DEFECT_CLASS_STATUS_CONFIG: Record<
|
|
||||||
TicketDefectClassTicketStatus,
|
|
||||||
{
|
|
||||||
label: string;
|
|
||||||
badgeClassName: string;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
unassigned: {
|
|
||||||
label: 'Unassigned',
|
|
||||||
badgeClassName:
|
|
||||||
'bg-zinc-500/15 text-zinc-600 dark:bg-zinc-500/20 dark:text-zinc-300',
|
|
||||||
},
|
|
||||||
assigned: {
|
|
||||||
label: 'Assigned',
|
|
||||||
badgeClassName:
|
|
||||||
'bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400',
|
|
||||||
},
|
|
||||||
under_review: {
|
|
||||||
label: 'Under Review',
|
|
||||||
badgeClassName:
|
|
||||||
'bg-violet-500/15 text-violet-600 dark:bg-violet-500/20 dark:text-violet-400',
|
|
||||||
},
|
|
||||||
approved: {
|
|
||||||
label: 'Approved',
|
|
||||||
badgeClassName:
|
|
||||||
'bg-emerald-500/15 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400',
|
|
||||||
},
|
|
||||||
rejected: {
|
|
||||||
label: 'Rejected',
|
|
||||||
badgeClassName:
|
|
||||||
'bg-orange-500/15 text-orange-600 dark:bg-orange-500/20 dark:text-orange-400',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { API_ROUTES } from '@/constants/apiRoutes';
|
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||||
import type {
|
import type {
|
||||||
DetectionCoordinatesParams,
|
|
||||||
DetectionCoordinatesResponse,
|
|
||||||
DiscardDetectionPayload,
|
DiscardDetectionPayload,
|
||||||
DiscardDetectionResponse,
|
DiscardDetectionResponse,
|
||||||
ReviewDetectionRepairProofPayload,
|
ReviewDetectionRepairProofPayload,
|
||||||
@@ -11,36 +9,6 @@ import type {
|
|||||||
import axiosClient from '../axios/axios';
|
import axiosClient from '../axios/axios';
|
||||||
|
|
||||||
export const detectionService = {
|
export const detectionService = {
|
||||||
getCoordinates: async (
|
|
||||||
videoId: string,
|
|
||||||
params?: DetectionCoordinatesParams,
|
|
||||||
): Promise<DetectionCoordinatesResponse> => {
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
|
|
||||||
params?.class_name?.forEach((className) => {
|
|
||||||
searchParams.append('class_name', className);
|
|
||||||
});
|
|
||||||
if (params?.skip !== undefined) {
|
|
||||||
searchParams.set('skip', String(params.skip));
|
|
||||||
}
|
|
||||||
if (params?.limit !== undefined) {
|
|
||||||
searchParams.set('limit', String(params.limit));
|
|
||||||
}
|
|
||||||
if (params?.assignment_status) {
|
|
||||||
searchParams.set('assignment_status', params.assignment_status);
|
|
||||||
}
|
|
||||||
if (params?.search) {
|
|
||||||
searchParams.set('search', params.search);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await axiosClient.get<DetectionCoordinatesResponse>(
|
|
||||||
API_ROUTES.VIDEOS.COORDINATES(videoId),
|
|
||||||
{ params: searchParams },
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
discardDetection: async (
|
discardDetection: async (
|
||||||
detectionId: number,
|
detectionId: number,
|
||||||
payload: DiscardDetectionPayload,
|
payload: DiscardDetectionPayload,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import type {
|
|||||||
TicketExtensionRequestsResponse,
|
TicketExtensionRequestsResponse,
|
||||||
TicketExtensionRequestsParams,
|
TicketExtensionRequestsParams,
|
||||||
TicketOverviewDetail,
|
TicketOverviewDetail,
|
||||||
TicketDefectClassesResponse,
|
|
||||||
TicketListParams,
|
TicketListParams,
|
||||||
TicketListResponse,
|
TicketListResponse,
|
||||||
TicketTimelineResponse,
|
TicketTimelineResponse,
|
||||||
@@ -62,15 +61,6 @@ export const ticketService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getTicketDefectClasses: async (
|
|
||||||
ticketId: string,
|
|
||||||
): Promise<TicketDefectClassesResponse> => {
|
|
||||||
const response = await axiosClient.get<TicketDefectClassesResponse>(
|
|
||||||
API_ROUTES.TICKETS.DEFECT_CLASSES(ticketId),
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
getAssignableUsers: async (
|
getAssignableUsers: async (
|
||||||
params?: AssignableTicketUsersParams,
|
params?: AssignableTicketUsersParams,
|
||||||
): Promise<AssignableTicketUsersResponse> => {
|
): Promise<AssignableTicketUsersResponse> => {
|
||||||
|
|||||||
@@ -5,38 +5,6 @@ export type DetectionClassCount = {
|
|||||||
unique_count: number;
|
unique_count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DetectionCoordinateClass = {
|
|
||||||
class_name: string;
|
|
||||||
display_name: string;
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DetectionCoordinateItem = {
|
|
||||||
id: number;
|
|
||||||
class_name: string;
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
confidence: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DetectionAssignmentStatus = 'assigned' | 'unassigned';
|
|
||||||
|
|
||||||
export type DetectionCoordinatesParams = {
|
|
||||||
skip?: number;
|
|
||||||
limit?: number;
|
|
||||||
class_name?: string[];
|
|
||||||
assignment_status?: DetectionAssignmentStatus;
|
|
||||||
search?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DetectionCoordinatesResponse = {
|
|
||||||
total: number;
|
|
||||||
limit: number;
|
|
||||||
truncated: boolean;
|
|
||||||
classes: DetectionCoordinateClass[];
|
|
||||||
items: DetectionCoordinateItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DetectionBoundingBox = {
|
export type DetectionBoundingBox = {
|
||||||
x1: number;
|
x1: number;
|
||||||
y1: number;
|
y1: number;
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
export type TicketDefectClassTicketStatus =
|
|
||||||
| 'unassigned'
|
|
||||||
| 'assigned'
|
|
||||||
| 'under_review'
|
|
||||||
| 'approved'
|
|
||||||
| 'rejected';
|
|
||||||
|
|
||||||
export interface TicketDefectClassItem {
|
|
||||||
class_name: string;
|
|
||||||
display_name: string;
|
|
||||||
assignment_status: TicketDefectClassTicketStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TicketDefectClassesResponse {
|
|
||||||
ticket_id: string;
|
|
||||||
video_id: string;
|
|
||||||
ticket_status: TicketDefectClassTicketStatus;
|
|
||||||
defect_classes: TicketDefectClassItem[];
|
|
||||||
}
|
|
||||||
@@ -60,6 +60,7 @@ export interface TicketAssignmentSummary {
|
|||||||
pending_detections: number;
|
pending_detections: number;
|
||||||
not_submitted_detections: number;
|
not_submitted_detections: number;
|
||||||
is_complete: boolean;
|
is_complete: boolean;
|
||||||
|
is_overdue: boolean;
|
||||||
due_at: string | null;
|
due_at: string | null;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,5 @@ export * from './status';
|
|||||||
export * from './list';
|
export * from './list';
|
||||||
export * from './detail';
|
export * from './detail';
|
||||||
export * from './actions';
|
export * from './actions';
|
||||||
export * from './defect-class';
|
|
||||||
export * from './events';
|
export * from './events';
|
||||||
export * from './timeline';
|
export * from './timeline';
|
||||||
|
|||||||
Reference in New Issue
Block a user