3 Commits

21 changed files with 1494 additions and 130 deletions

View File

@@ -0,0 +1,244 @@
'use client';
import { useMemo, useState } from 'react';
import {
ArrowRight,
Mail,
MapPin,
Phone,
Send,
ShieldCheck,
Users,
} from 'lucide-react';
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import type { AssignableTicketUser } from '@/types';
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
import {
formatCompactRangeCoordinate,
formatRangeCoordinate,
type AssignmentRangePoint,
} from './assignmentRange';
interface AssignTicketFormProps {
ticketId: string;
videoId?: string;
selectedClassNames: string[];
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
onAssigned: () => void;
}
export function AssignTicketForm({
ticketId,
videoId,
selectedClassNames,
startPoint,
endPoint,
onAssigned,
}: AssignTicketFormProps) {
const [selectedUserId, setSelectedUserId] = useState('');
const [selectedContractor, setSelectedContractor] =
useState<AssignableTicketUser | null>(null);
const [dueDate, setDueDate] = useState<Date>();
const [assignNote, setAssignNote] = useState('');
const assignMutation = useAssignTicketMutation(ticketId, videoId);
const today = useMemo(() => {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}, []);
const maxDueMonth = useMemo(
() => new Date(today.getFullYear() + 20, 11),
[today],
);
const hasIncompleteLocationRange = Boolean(startPoint) !== Boolean(endPoint);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Users className="text-primary" />
Contractor and schedule
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-5">
<div className="space-y-2">
<Label>
Contractor <span className="text-destructive">*</span>
</Label>
<AssignableWorkerSelect
value={selectedUserId}
onValueChange={(userId, user) => {
setSelectedUserId(userId);
setSelectedContractor(user);
}}
disabled={assignMutation.isPending}
/>
</div>
{selectedContractor ? (
<div className="space-y-2 rounded-lg border bg-muted/30 p-4">
<p className="text-sm font-medium">Contractor details</p>
{selectedContractor.phone_number ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Phone className="size-4 shrink-0" />
{selectedContractor.phone_number}
</p>
) : null}
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="size-4 shrink-0" />
<span className="truncate">{selectedContractor.email}</span>
</p>
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<ShieldCheck className="size-4 shrink-0" />
{selectedContractor.role.name}
</p>
</div>
) : 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">
<Label htmlFor="assign-due-date">
Due date <span className="text-destructive">*</span>
</Label>
<DatePickerSimple
id="assign-due-date"
value={dueDate}
onChange={setDueDate}
showLabel={false}
placeholder="Select due date"
disabled={assignMutation.isPending}
className="w-full"
startMonth={today}
endMonth={maxDueMonth}
disabledDates={{ before: today }}
/>
</div>
<div className="space-y-2">
<Label htmlFor="assign-note">Assignment note (optional)</Label>
<Textarea
id="assign-note"
value={assignNote}
onChange={(event) => setAssignNote(event.target.value)}
placeholder="Add instructions or notes for the contractor..."
disabled={assignMutation.isPending}
/>
</div>
<Button
type="button"
disabled={
!selectedUserId ||
!selectedContractor ||
!dueDate ||
hasIncompleteLocationRange ||
assignMutation.isPending
}
onClick={() => {
if (
!selectedUserId ||
!selectedContractor ||
!dueDate ||
hasIncompleteLocationRange
) {
return;
}
const note = assignNote.trim() || undefined;
const hasCriteria =
selectedClassNames.length > 0 ||
Boolean(startPoint && endPoint);
assignMutation.mutate(
{
assigned_to_user_id: Number(selectedUserId),
assigned_to_email: selectedContractor.email,
due_at: dueDate.toISOString(),
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();
},
},
);
}}
className="w-full sm:w-auto"
>
<Send />
{assignMutation.isPending ? 'Assigning...' : 'Assign ticket'}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,396 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import dynamic from 'next/dynamic';
import { AlertTriangle, MapPinned, RefreshCw } from 'lucide-react';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import type {
DetectionCoordinateClass,
DetectionCoordinateItem,
DetectionCoordinatesResponse,
} from '@/types';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type { AssignmentRangePoint } from './assignmentRange';
import { AssignmentRangeActions } from './AssignmentRangeActions';
type AssignmentIssuesMapProps = {
data?: DetectionCoordinatesResponse;
isLoading: boolean;
isFetchingMore: boolean;
isError: boolean;
hasMore: boolean;
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
onSetStart: (point: AssignmentRangePoint) => void;
onSetEnd: (point: AssignmentRangePoint) => void;
onLoadMore: () => void;
onRetry: () => void;
};
type ClientAssignmentIssuesMapProps = {
items: DetectionCoordinateItem[];
classes: DetectionCoordinateClass[];
selectedDetectionId: number | null;
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
onSelectDetection: (detectionId: number) => void;
onSetStart: (point: AssignmentRangePoint) => void;
onSetEnd: (point: AssignmentRangePoint) => void;
};
function isValidCoordinate(item: DetectionCoordinateItem) {
return (
Number.isFinite(item.latitude) &&
Number.isFinite(item.longitude) &&
item.latitude >= -90 &&
item.latitude <= 90 &&
item.longitude >= -180 &&
item.longitude <= 180
);
}
const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
async () => {
const { CircleMarker, MapContainer, Popup, TileLayer, Tooltip, useMap } =
await import('react-leaflet');
const { canvas } = await import('leaflet');
function FitMapToIssues({ items }: { items: DetectionCoordinateItem[] }) {
const map = useMap();
useEffect(() => {
if (items.length === 0) return;
if (items.length === 1) {
map.setView([items[0].latitude, items[0].longitude], 17);
return;
}
map.fitBounds(
items.map(
(item) => [item.latitude, item.longitude] as [number, number],
),
{
padding: [32, 32],
maxZoom: 17,
},
);
}, [items, map]);
return null;
}
return function ClientAssignmentIssuesMapInner({
items,
classes,
selectedDetectionId,
startPoint,
endPoint,
onSelectDetection,
onSetStart,
onSetEnd,
}: ClientAssignmentIssuesMapProps) {
const markerRenderer = useMemo(() => canvas({ tolerance: 12 }), []);
const displayNames = new Map(
classes.map((item) => [item.class_name, item.display_name]),
);
const initialCenter: [number, number] = [
items[0].latitude,
items[0].longitude,
];
return (
<MapContainer
center={initialCenter}
zoom={15}
scrollWheelZoom
className="h-full w-full"
>
<TileLayer
attribution="&copy; OpenStreetMap contributors"
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<FitMapToIssues items={items} />
{items.map((item) => {
const visual = getDefectVisual(item.class_name);
const IssueIcon = visual.icon;
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 =
displayNames.get(item.class_name) ??
item.class_name.replaceAll('_', ' ');
const point: AssignmentRangePoint = {
detectionId: item.id,
latitude: item.latitude,
longitude: item.longitude,
};
return (
<CircleMarker
key={item.id}
center={[item.latitude, item.longitude]}
renderer={markerRenderer}
radius={isSelected || isRangeIssue ? 11 : 8}
pathOptions={{
color:
isSelected || isRangeIssue
? '#ffffff'
: visual.boundingBoxColor,
fillColor: rangeColor,
fillOpacity: isSelected || isRangeIssue ? 1 : 0.82,
weight: isSelected || isRangeIssue ? 4 : 2,
}}
eventHandlers={{
click: () => onSelectDetection(item.id),
}}
>
<Popup minWidth={240} maxWidth={280}>
<div className="w-60">
<div className="flex items-start gap-3 border-b pb-3 pr-4">
<span
className="flex size-9 shrink-0 items-center justify-center rounded-full"
style={{
backgroundColor: `${visual.boundingBoxColor}1f`,
color: visual.boundingBoxColor,
}}
>
<IssueIcon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="!m-0 truncate text-sm font-semibold leading-5">
{displayName}
</p>
<p className="!m-0 text-xs leading-4 text-muted-foreground">
Detection #{item.id}
</p>
</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>
<dl className="!m-0 space-y-2 py-3 text-sm">
<div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">Confidence</dt>
<dd className="!m-0 font-medium tabular-nums">
{Math.round(item.confidence * 100)}%
</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">Latitude</dt>
<dd className="!m-0 font-mono text-xs tabular-nums">
{item.latitude.toFixed(6)}
</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>
<div className="border-t pt-3">
<AssignmentRangeActions
point={point}
startPoint={startPoint}
endPoint={endPoint}
onSetStart={onSetStart}
onSetEnd={onSetEnd}
stretch
/>
</div>
</div>
</Popup>
{rangeLabel ? (
<Tooltip permanent direction="center" opacity={1}>
{rangeLabel}
</Tooltip>
) : null}
</CircleMarker>
);
})}
</MapContainer>
);
};
},
{
ssr: false,
loading: () => <div className="size-full animate-pulse bg-muted" />,
},
);
export function AssignmentIssuesMap({
data,
isLoading,
isFetchingMore,
isError,
hasMore,
startPoint,
endPoint,
onSetStart,
onSetEnd,
onLoadMore,
onRetry,
}: AssignmentIssuesMapProps) {
const [selectedDetectionId, setSelectedDetectionId] = useState<number | null>(
null,
);
const validItems = useMemo(
() => (data?.items ?? []).filter(isValidCoordinate),
[data?.items],
);
const total = data?.total ?? 0;
const loadedCount = Math.min(data?.items.length ?? 0, total);
useEffect(() => {
if (
selectedDetectionId !== null &&
!validItems.some((item) => item.id === selectedDetectionId)
) {
setSelectedDetectionId(null);
}
}, [selectedDetectionId, validItems]);
return (
<Card className="overflow-hidden">
<CardHeader className="border-b">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-secondary p-2">
<MapPinned className="size-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">Issues on Map</CardTitle>
<CardDescription>
Click a marker, then choose Start or End.
</CardDescription>
</div>
</div>
{data ? (
<span className="shrink-0 text-sm text-muted-foreground">
{validItems.length} mapped
</span>
) : null}
</div>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="h-[440px] animate-pulse bg-muted" />
) : isError ? (
<div className="flex min-h-72 flex-col items-center justify-center gap-3 px-6 text-center">
<AlertTriangle className="size-7 text-destructive" />
<div>
<p className="font-medium">Unable to load issue locations.</p>
<p className="mt-1 text-sm text-muted-foreground">
Check the connection and try again.
</p>
</div>
<Button type="button" variant="outline" onClick={onRetry}>
<RefreshCw />
Retry
</Button>
</div>
) : validItems.length === 0 ? (
<div className="flex min-h-72 flex-col items-center justify-center px-6 text-center">
<MapPinned className="size-7 text-muted-foreground" />
<p className="mt-3 font-medium">No issue locations available.</p>
<p className="mt-1 text-sm text-muted-foreground">
Detections without valid coordinates cannot be placed on the map.
</p>
</div>
) : (
<>
<div className="h-[440px] min-w-0 bg-muted">
<ClientAssignmentIssuesMap
items={validItems}
classes={data?.classes ?? []}
selectedDetectionId={selectedDetectionId}
startPoint={startPoint}
endPoint={endPoint}
onSelectDetection={setSelectedDetectionId}
onSetStart={onSetStart}
onSetEnd={onSetEnd}
/>
</div>
<div
className="flex flex-wrap items-center gap-x-4 gap-y-2 border-t px-4 py-3"
aria-label="Map legend"
>
<span className="text-sm font-medium">Map legend</span>
{(data?.classes ?? []).map((item) => {
const visual = getDefectVisual(item.class_name);
const IssueIcon = visual.icon;
return (
<div
key={item.class_name}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<span
className="flex size-6 items-center justify-center rounded-full"
style={{
backgroundColor: `${visual.boundingBoxColor}1f`,
color: visual.boundingBoxColor,
}}
>
<IssueIcon className="size-3.5" />
</span>
<span>{item.display_name}</span>
</div>
);
})}
</div>
<div className="flex items-center justify-between gap-3 border-t px-4 py-3">
<span className="text-sm text-muted-foreground">
Showing {loadedCount} of {total}
</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>
</>
)}
</CardContent>
</Card>
);
}

View File

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

View File

@@ -0,0 +1,228 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { X } from 'lucide-react';
import { MultiSelectPopover } from '@/components/form/MultiSelectPopover';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
useDetectionCoordinatesQuery,
useTicketDetectionsQuery,
} from '../../../hooks/useTicketQueries';
import {
formatCompactRangeCoordinate,
formatRangeCoordinate,
type AssignmentRangePoint,
} from './assignmentRange';
import { AssignmentIssuesMap } from './AssignmentIssuesMap';
import { useIssueColumns } from './IssueColumns';
import { IssueTable } from './IssueTable';
const DEFAULT_PAGE_SIZE = 10;
const MAP_PAGE_SIZE = 50;
interface IssueTypeOption {
class_name: string;
display_name: string;
count: number;
}
interface ChooseIssuesSectionProps {
videoId?: string;
issueTypes: IssueTypeOption[];
selectedIssueTypes: string[];
startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null;
onSelectedIssueTypesChange: (values: string[]) => void;
onStartPointChange: (point: AssignmentRangePoint | null) => void;
onEndPointChange: (point: AssignmentRangePoint | null) => void;
}
export function ChooseIssuesSection({
videoId,
issueTypes,
selectedIssueTypes,
startPoint,
endPoint,
onSelectedIssueTypesChange,
onStartPointChange,
onEndPointChange,
}: ChooseIssuesSectionProps) {
const [skip, setSkip] = useState(0);
const [limit, setLimit] = useState(DEFAULT_PAGE_SIZE);
const handleSetStart = useCallback(
(point: AssignmentRangePoint) => {
onStartPointChange(point);
onEndPointChange(null);
},
[onEndPointChange, onStartPointChange],
);
const handleSetEnd = useCallback(
(point: AssignmentRangePoint) => {
if (point.detectionId === startPoint?.detectionId) return;
onEndPointChange(point);
},
[onEndPointChange, startPoint?.detectionId],
);
const columns = useIssueColumns({
startPoint,
endPoint,
onSetStart: handleSetStart,
onSetEnd: handleSetEnd,
});
const issueTypeOptions = useMemo(
() =>
issueTypes.map((issue) => ({
value: issue.class_name,
label: issue.display_name,
})),
[issueTypes],
);
const handleIssueTypesChange = useCallback(
(values: string[]) => {
onSelectedIssueTypesChange(values);
onStartPointChange(null);
onEndPointChange(null);
},
[onEndPointChange, onSelectedIssueTypesChange, onStartPointChange],
);
useEffect(() => {
setSkip(0);
}, [selectedIssueTypes]);
const queryParams = useMemo(
() => ({
skip,
limit,
class_name:
selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined,
sort: 'timestamp_asc',
}),
[limit, selectedIssueTypes, skip],
);
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 mapData = useMemo(() => {
const pages = coordinatesQuery.data?.pages;
if (!pages?.length) return undefined;
const latestPage = pages[pages.length - 1];
return {
...latestPage,
items: pages.flatMap((page) => page.items),
truncated: Boolean(coordinatesQuery.hasNextPage),
};
}, [coordinatesQuery.data?.pages, coordinatesQuery.hasNextPage]);
return (
<div className="min-w-0 space-y-5">
<Card size="sm" aria-label="Issue filters">
<CardHeader>
<CardTitle>Filters</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-2">
<MultiSelectPopover
label="Issue Types"
options={issueTypeOptions}
values={selectedIssueTypes}
onValuesChange={handleIssueTypesChange}
searchPlaceholder="Search issue types"
emptyMessage="No issue types found."
emptySelectionLabel="All"
align="start"
/>
{startPoint ? (
<Badge
variant="outline"
className="h-8 gap-1.5 px-2.5 font-normal"
title={formatRangeCoordinate(startPoint)}
>
<span className="font-medium">Start</span>
<span className="font-mono">
{formatCompactRangeCoordinate(startPoint)}
</span>
</Badge>
) : 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
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
columns={columns}
issues={result?.items ?? []}
isLoading={detectionsQuery.isLoading}
isError={detectionsQuery.isError}
toolbar={null}
skip={skip}
limit={limit}
total={result?.total ?? 0}
onPageChange={setSkip}
onLimitChange={setLimit}
/>
</div>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import { useMemo } from 'react';
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 { AssignmentRangePoint } from './assignmentRange';
import { AssignmentRangeActions } from './AssignmentRangeActions';
function formatTimestamp(seconds: number) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
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(
() => [
{
id: 'detection_id',
header: 'Detection ID',
size: 140,
cell: ({ row }) => `#${row.original.detection.id}`,
},
{
id: 'issue_type',
header: 'Issue Type',
size: 180,
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: 'coordinates',
header: 'Coordinates',
size: 220,
cell: ({ row }) => {
const issueId = row.original.detection.id;
const { latitude, longitude } = row.original.location;
if (latitude === null || longitude === null) {
return <span className="text-muted-foreground">Unavailable</span>;
}
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',
header: 'Timestamp',
size: 130,
cell: ({ row }) =>
formatTimestamp(row.original.frame.timestamp_seconds),
},
{
id: 'range_actions',
header: 'Set range',
size: 150,
cell: ({ row }) => {
const detectionId = row.original.detection.id;
const { latitude, longitude } = row.original.location;
const hasValidCoordinate =
latitude !== null &&
longitude !== null &&
Number.isFinite(latitude) &&
Number.isFinite(longitude) &&
latitude >= -90 &&
latitude <= 90 &&
longitude >= -180 &&
longitude <= 180;
if (!hasValidCoordinate) {
return <span className="text-muted-foreground">Unavailable</span>;
}
const point: AssignmentRangePoint = {
detectionId,
latitude,
longitude,
};
return (
<AssignmentRangeActions
point={point}
startPoint={startPoint}
endPoint={endPoint}
onSetStart={onSetStart}
onSetEnd={onSetEnd}
/>
);
},
},
],
[endPoint, onSetEnd, onSetStart, startPoint],
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table';
import type { DetectionResultItem } from '@/types';
interface IssueTableProps {
columns: ColumnDef<DetectionResultItem>[];
issues: DetectionResultItem[];
isLoading: boolean;
isError: boolean;
toolbar: ReactNode;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
}
export function IssueTable({
columns,
issues,
isLoading,
isError,
toolbar,
skip,
limit,
total,
onPageChange,
onLimitChange,
}: IssueTableProps) {
return (
<DataTable
title="Issue details"
columns={columns}
data={issues}
isLoading={isLoading}
toolbar={toolbar}
emptyTitle={isError ? 'Failed to load issues.' : 'No issues found.'}
emptyDescription={
isError
? 'Please try again.'
: 'No detections match the current filters.'
}
pagination={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange,
}}
/>
);
}

View File

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

View File

@@ -0,0 +1,161 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { AlertTriangle, ArrowLeft, LockKeyhole } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import { ROUTES } from '@/utils/routes';
import { useTicketOverviewQuery } from '../../hooks/useTicketQueries';
import type { AssignmentRangePoint } from './components/assignmentRange';
import { AssignTicketForm } from './components/AssignTicketForm';
import { ChooseIssuesSection } from './components/ChooseIssuesSection';
function AssignmentPageSkeleton() {
return (
<div className="space-y-5" aria-label="Loading assignment workspace">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-7 w-56" />
<Skeleton className="h-4 w-40" />
</div>
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<Skeleton className="h-[540px] rounded-xl" />
<Skeleton className="h-[430px] rounded-xl" />
</div>
</div>
);
}
export default function TicketAssignmentPage() {
const router = useRouter();
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 overview = overviewQuery.data;
const videoId = overview?.video_id ?? overview?.video?.id ?? undefined;
const ticketDetailUrl = ROUTES.TICKET_DETAIL(ticketId);
const backToTicket = () => router.push(ticketDetailUrl);
if (overviewQuery.isLoading) {
return <AssignmentPageSkeleton />;
}
if (overviewQuery.isError || !overview) {
return (
<Card>
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
<AlertTriangle className="size-8 text-destructive" />
<div>
<p className="font-semibold">
Unable to open the assignment workspace.
</p>
<p className="mt-1 text-sm text-muted-foreground">
Ticket information is unavailable.
</p>
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={backToTicket}>
Back to ticket
</Button>
<Button type="button" onClick={() => void overviewQuery.refetch()}>
Retry
</Button>
</div>
</CardContent>
</Card>
);
}
const ticketLabel = overview.ticket_name || overview.id;
return (
<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">
<Button
type="button"
variant="secondary"
size="icon"
onClick={backToTicket}
aria-label="Back to ticket"
className="shrink-0"
>
<ArrowLeft />
</Button>
<div className="min-w-0 space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">
Assign ticket
</h1>
<p className="truncate text-sm text-muted-foreground">
Ticket: {ticketLabel}
</p>
</div>
</header>
<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">
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
<ChooseIssuesSection
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:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
<PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={
<Card>
<CardContent className="flex min-h-72 flex-col items-center justify-center text-center">
<LockKeyhole className="size-8 text-muted-foreground" />
<p className="mt-3 font-semibold">
Assignment access required
</p>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
You do not have permission to assign this ticket.
</p>
<Button
type="button"
variant="outline"
className="mt-4"
onClick={backToTicket}
>
Back to ticket
</Button>
</CardContent>
</Card>
}
>
<AssignTicketForm
ticketId={ticketId}
videoId={videoId}
selectedClassNames={selectedIssueTypes}
startPoint={startPoint}
endPoint={endPoint}
onAssigned={() => {
setStartPoint(null);
setEndPoint(null);
}}
/>
</PermissionGuard>
</aside>
</div>
</main>
);
}

View File

@@ -4,8 +4,12 @@ import { ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { TicketOverviewDetail } from '@/types';
import { AssignTicketAction } from './actions/AssignTicketAction';
export function TicketDetailHeader({
ticket,
}: {
@@ -22,10 +26,10 @@ export function TicketDetailHeader({
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="secondary"
onClick={() => router.push('/ticket')}
>
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<AssignTicketAction ticketId={ticket.id} />
</PermissionGuard>
<Button variant="secondary" onClick={() => router.push('/ticket')}>
<ArrowLeft className="size-4" />
Back to List
</Button>

View File

@@ -5,7 +5,6 @@ import { PermissionGuard } from '@/guards';
import type { TicketDetail } from '@/types';
import { AssignedContractorAction } from './actions/AssignedContractorAction';
import { AssignTicketAction } from './actions/AssignTicketAction';
import { ClosedTicketAction } from './actions/ClosedTicketAction';
import { NoTicketAction } from './actions/NoTicketAction';
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
@@ -18,14 +17,7 @@ interface TicketStatusActionsProps {
function getTicketAction(ticket: TicketDetail) {
if (ticket.assignment_status === 'unassigned') {
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={<NoTicketAction />}
>
<AssignTicketAction ticket={ticket} />
</PermissionGuard>
);
return null;
}
if (ticket.assignment_status === 'assigned') {
@@ -71,5 +63,7 @@ function getTicketAction(ticket: TicketDetail) {
}
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
return <div className="space-y-3">{getTicketAction(ticket)}</div>;
const action = getTicketAction(ticket);
return action ? <div className="space-y-3">{action}</div> : null;
}

View File

@@ -1,111 +1,21 @@
'use client';
import { useMemo, useState } from 'react';
import { Users, Mail, Phone, Send, ShieldCheck } from 'lucide-react';
import { Users } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
import type { AssignableTicketUser, TicketDetail } from '@/types';
import { ROUTES } from '@/utils/routes';
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
import { TicketActionCard } from './TicketActionCard';
export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
const [selectedUserId, setSelectedUserId] = useState('');
const [selectedContractor, setSelectedContractor] =
useState<AssignableTicketUser | null>(null);
const [dueDate, setDueDate] = useState<Date>();
const [assignNote, setAssignNote] = useState('');
const assignMutation = useAssignTicketMutation(ticket.id);
const today = useMemo(() => {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}, []);
const maxDueMonth = useMemo(
() => new Date(today.getFullYear() + 20, 11),
[today],
);
export function AssignTicketAction({ ticketId }: { ticketId: string }) {
const router = useRouter();
return (
<TicketActionCard icon={Users} title="Assign to Contractor">
<div className="space-y-4">
<div className="space-y-2">
<Label>Contractor *</Label>
<AssignableWorkerSelect
value={selectedUserId}
onValueChange={(userId, user) => {
setSelectedUserId(userId);
setSelectedContractor(user);
}}
disabled={assignMutation.isPending}
/>
</div>
{selectedContractor ? (
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
<p className="text-sm font-medium">Contractor Details</p>
{selectedContractor.phone_number ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Phone className="size-4 shrink-0" />
{selectedContractor.phone_number}
</p>
) : null}
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="size-4 shrink-0" />
<span className="truncate">{selectedContractor.email}</span>
</p>
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<ShieldCheck className="size-4 shrink-0" />
{selectedContractor.role.name}
</p>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="assign-due-date">
Due Date <span className="text-destructive">*</span>
</Label>
<DatePickerSimple
id="assign-due-date"
value={dueDate}
onChange={setDueDate}
showLabel={false}
placeholder="Select due date"
disabled={assignMutation.isPending}
className="w-full"
startMonth={today}
endMonth={maxDueMonth}
disabledDates={{ before: today }}
/>
</div>
<div className="space-y-2">
<Label>Assignment Note (Optional)</Label>
<Textarea
value={assignNote}
onChange={(event) => setAssignNote(event.target.value)}
placeholder="Add any instructions or notes for the contractor..."
/>
</div>
<Button
disabled={!selectedUserId || !dueDate || assignMutation.isPending}
onClick={() => {
if (!selectedUserId || !dueDate) return;
assignMutation.mutate({
assigned_to_user_id: Number(selectedUserId),
defect_class: ticket.defect_class,
due_at: dueDate.toISOString(),
note: assignNote || undefined,
});
}}
className="w-full"
type="button"
onClick={() => router.push(ROUTES.TICKET_ASSIGN(ticketId))}
>
<Send />
<Users />
Assign Ticket
</Button>
</div>
</TicketActionCard>
);
}

View File

@@ -13,6 +13,7 @@ import { detectionService, ticketService, videoService } from '@/services/api';
import type {
AssignTicketPayload,
CloseTicketPayload,
DetectionCoordinatesParams,
DiscardDetectionPayload,
RequestTicketExtensionPayload,
ReviewDetectionRepairProofPayload,
@@ -91,7 +92,7 @@ export function useTicketDefectClassesQuery(ticketId: string | undefined) {
});
}
export function useTicketClassDetectionsQuery(
export function useTicketDetectionsQuery(
videoId: string | undefined,
params: VideoDetectionsParams,
) {
@@ -118,10 +119,54 @@ export function useTicketClassDetectionsQuery(
queryKey: ticketKeys.classDetections(videoId ?? '', queryParams),
queryFn: () =>
videoService.getVideoDetections(videoId as string, queryParams),
enabled: Boolean(videoId && queryParams.class_name),
enabled: Boolean(videoId),
});
}
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,
});
}
export function useTicketClassDetectionsQuery(
videoId: string | undefined,
params: VideoDetectionsParams,
) {
return useTicketDetectionsQuery(videoId, params);
}
export function useTicketClassReviewDetectionsQuery(
videoId: string | undefined,
defectClass: string | undefined,
@@ -276,19 +321,30 @@ function mergeTicketDetailResponse(
};
}
export function useAssignTicketMutation(ticketId: string) {
export function useAssignTicketMutation(ticketId: string, videoId?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: AssignTicketPayload) =>
ticketService.assignTicket(ticketId, payload),
onSuccess: (ticket, payload) => {
onSuccess: async () => {
toast.success('Ticket assigned');
queryClient.setQueryData<TicketDetail>(
ticketKeys.classDetail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket),
);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
await Promise.all([
queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId),
}),
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'),
});

View File

@@ -1,4 +1,8 @@
import type { TicketListParams, VideoDetectionsParams } from '@/types';
import type {
DetectionCoordinatesParams,
TicketListParams,
VideoDetectionsParams,
} from '@/types';
export const ticketKeys = {
all: ['tickets'] as const,
@@ -13,6 +17,15 @@ export const ticketKeys = {
[...ticketKeys.details(), 'video', videoId, 'detections'] as const,
classDetections: (videoId: string, params: VideoDetectionsParams) =>
[...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,
classReviewDetections: (
videoId: string,
defectClass: string,

View File

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

View File

@@ -57,6 +57,7 @@ export const API_ROUTES = {
ANNOTATION_FRAMES: (id: string) =>
`/biz/api/v1/results/${id}/annotation-frames`,
DETECTIONS: (id: string) => `/biz/api/v1/results/${id}/detections`,
COORDINATES: (id: string) => `/biz/api/v1/results/${id}/coordinates`,
},
DETECTIONS: {
DISCARD: (id: number) => `/biz/api/v1/detections/${id}/discard`,
@@ -67,7 +68,7 @@ export const API_ROUTES = {
CLASS_DETAIL: (id: string, defectClass: string) =>
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
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) =>
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-requests`,
EXTENSION_REQUESTS: (ticketId: string) =>

View File

@@ -1,5 +1,7 @@
import { API_ROUTES } from '@/constants/apiRoutes';
import type {
DetectionCoordinatesParams,
DetectionCoordinatesResponse,
DiscardDetectionPayload,
DiscardDetectionResponse,
ReviewDetectionRepairProofPayload,
@@ -9,6 +11,36 @@ import type {
import axiosClient from '../axios/axios';
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 (
detectionId: number,
payload: DiscardDetectionPayload,

View File

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

View File

@@ -79,6 +79,9 @@ export const videoService = {
sort: params?.sort ?? 'timestamp_asc',
search: params?.search,
},
paramsSerializer: {
indexes: null,
},
},
);
return response.data;

View File

@@ -5,6 +5,38 @@ export type DetectionClassCount = {
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 = {
x1: number;
y1: number;

View File

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

View File

@@ -16,6 +16,7 @@ export const ROUTES = {
UPLOAD: '/upload',
TICKET: '/ticket',
TICKET_DETAIL: (ticketId: string) => `/ticket/${ticketId}`,
TICKET_ASSIGN: (ticketId: string) => `/ticket/${ticketId}/assign`,
TICKET_CLASS_REVIEW: (ticketId: string, defectClass: string) =>
`/ticket/${ticketId}/${defectClass}/review`,
} as const;