feat(ticket): support criteria-based global ticket assignment
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user