6 Commits

33 changed files with 2055 additions and 357 deletions

View File

@@ -13,11 +13,11 @@ const ModulesLayout = ({
<AuthGuard>
<SidebarProvider>
<AppSidebar />
<main className="flex flex-1 h-screen w-full flex-col overflow-hidden">
<div className="scroll-stable flex-1 overflow-auto">
<div className="mx-auto flex min-h-full w-full max-w-380 flex-col">
<main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden">
<div className="scroll-stable min-w-0 flex-1 overflow-auto">
<div className="mx-auto flex min-h-full w-full min-w-0 max-w-380 flex-col">
<ModuleShellHeader />
<div className="flex-1 p-6">{children}</div>
<div className="min-w-0 flex-1 p-6">{children}</div>
</div>
</div>
</main>

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

@@ -0,0 +1,267 @@
'use client';
import { useEffect } from 'react';
import { BriefcaseBusiness, CalendarClock, ListChecks } from 'lucide-react';
import { PersonInfo } from '@/components/person-avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import { cn } from '@/lib/utils';
import type { TicketActor, TicketAssignmentSummary } from '@/types';
import { formatDate } from '@/utils/date';
import { useTicketAssignmentsQuery } from '../../hooks/useTicketQueries';
interface TicketAssignmentsCardProps {
ticketId: string;
initialAssignments?: TicketAssignmentSummary[];
selectedAssignmentId?: number;
onAssignmentChange: (assignmentId: number) => void;
}
function formatLabel(value: string) {
return value
.replaceAll('_', ' ')
.replace(/\b\w/g, (character) => character.toUpperCase());
}
function AssignmentClassBadge({ className }: { className: string }) {
const visual = getDefectVisual(className);
const IssueIcon = visual.icon;
return (
<Badge
variant="outline"
className="h-6 gap-1.5 px-2 font-normal"
style={{
borderColor: `${visual.boundingBoxColor}66`,
backgroundColor: `${visual.boundingBoxColor}14`,
color: visual.boundingBoxColor,
}}
>
<IssueIcon />
{formatLabel(className)}
</Badge>
);
}
function AssignmentProgress({
assignment,
}: {
assignment: TicketAssignmentSummary;
}) {
const total = Math.max(assignment.item_count, 0);
const approved = Math.max(assignment.approved_detections, 0);
const progress = total > 0 ? Math.min((approved / total) * 100, 100) : 0;
return (
<div className="min-w-0 space-y-2">
<div className="flex min-w-0 flex-col gap-1 text-xs sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span className="font-medium text-foreground">Repair progress</span>
<span className="tabular-nums text-muted-foreground">
{approved} of {total} approved
</span>
</div>
<Progress value={assignment.is_complete ? 100 : progress} />
<div className="flex min-w-0 flex-wrap gap-x-4 gap-y-1 text-xs">
<span className="whitespace-nowrap text-emerald-600">
{assignment.approved_detections} approved
</span>
<span className="whitespace-nowrap text-amber-600">
{assignment.pending_detections} pending
</span>
<span className="whitespace-nowrap text-muted-foreground">
{assignment.not_submitted_detections} not submitted
</span>
</div>
</div>
);
}
function AssignmentLot({
assignment,
}: {
assignment: TicketAssignmentSummary;
}) {
const criteria = assignment.criteria;
const classNames = criteria?.class_names?.length
? criteria.class_names
: assignment.defect_class
? [assignment.defect_class]
: [];
const worker: TicketActor = {
user_id: assignment.assigned_to_user_id,
name: assignment.assigned_to_name,
email: assignment.assigned_to_email ?? null,
avatar_url: assignment.assigned_to_avatar_url ?? null,
};
return (
<div className="min-w-0 space-y-4 rounded-lg border p-3 sm:p-4">
<div className="min-w-0">
<p className="mb-2 text-xs font-medium text-muted-foreground">
Lot {assignment.id}
</p>
<PersonInfo person={worker} />
</div>
<div className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
{classNames.map((className) => (
<AssignmentClassBadge key={className} className={className} />
))}
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ListChecks className="size-3.5" />
{assignment.item_count}{' '}
{assignment.item_count === 1 ? 'issue' : 'issues'}
</span>
{assignment.due_at ? (
<span className="flex min-w-0 max-w-full items-center gap-1.5 text-xs text-muted-foreground">
<CalendarClock className="size-3.5" />
Due {formatDate(assignment.due_at)}
</span>
) : null}
</div>
<AssignmentProgress assignment={assignment} />
</div>
);
}
export function TicketAssignmentsCard({
ticketId,
initialAssignments,
selectedAssignmentId,
onAssignmentChange,
}: TicketAssignmentsCardProps) {
const assignmentsQuery = useTicketAssignmentsQuery(
ticketId,
initialAssignments,
);
const assignments = assignmentsQuery.data?.items ?? [];
const selectedAssignment =
assignments.find((assignment) => assignment.id === selectedAssignmentId) ??
assignments[0];
const activeAssignmentId = selectedAssignment
? String(selectedAssignment.id)
: '';
const resolvedAssignmentId = selectedAssignment?.id;
useEffect(() => {
if (
resolvedAssignmentId !== undefined &&
resolvedAssignmentId !== selectedAssignmentId
) {
onAssignmentChange(resolvedAssignmentId);
}
}, [onAssignmentChange, resolvedAssignmentId, selectedAssignmentId]);
return (
<Card className="min-w-0 max-w-full">
<CardHeader className="min-w-0 px-3 sm:px-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="flex items-center gap-2">
<BriefcaseBusiness className="size-5 text-primary" />
Assignment lots
</CardTitle>
<CardDescription className="mt-1">
Live ownership, criteria, and repair progress for this ticket.
</CardDescription>
</div>
{assignmentsQuery.data ? (
<Badge variant="secondary">{assignmentsQuery.data.total}</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="min-w-0 space-y-3 overflow-hidden px-3 sm:px-6">
{assignmentsQuery.isLoading ? (
Array.from({ length: 2 }).map((_, index) => (
<div
key={index}
className="h-40 animate-pulse rounded-lg bg-muted"
/>
))
) : assignmentsQuery.isError ? (
<div className="rounded-lg border border-dashed p-6 text-center">
<p className="text-sm font-medium">
Unable to load assignment lots.
</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
onClick={() => void assignmentsQuery.refetch()}
>
Try again
</Button>
</div>
) : selectedAssignment ? (
<div className="min-w-0 space-y-3">
<nav
aria-label="Assignment lots"
className="w-full max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain touch-pan-x"
>
<div
role="tablist"
aria-orientation="horizontal"
className="flex w-max min-w-full border-b"
>
{assignments.map((assignment) => {
const assignmentId = String(assignment.id);
const isActive = assignmentId === activeAssignmentId;
return (
<button
key={assignment.id}
id={`assignment-tab-${assignmentId}`}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={`assignment-panel-${assignmentId}`}
tabIndex={isActive ? 0 : -1}
className={cn(
'relative shrink-0 px-3 py-2.5 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
isActive
? 'text-foreground after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
onClick={() => onAssignmentChange(assignment.id)}
>
Lot {assignment.id}
</button>
);
})}
</div>
</nav>
<div
id={`assignment-panel-${activeAssignmentId}`}
role="tabpanel"
aria-labelledby={`assignment-tab-${activeAssignmentId}`}
className="min-w-0"
>
<AssignmentLot assignment={selectedAssignment} />
</div>
</div>
) : (
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
<p className="text-sm font-medium">No assignments yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Assignment lots will appear here after work is allocated.
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -13,15 +13,14 @@ import { cn } from '@/lib/utils';
import {
useDiscardDetectionMutation,
useTicketClassDetectionsQuery,
useTicketDetectionsQuery,
} from '../../hooks/useTicketQueries';
import { DetectionDiscardDialog } from './DetectionDiscardDialog';
interface TicketClassDetectionPreviewProps {
ticketId: string;
videoId?: string | null;
defectClassName: string;
displayName: string;
assignmentId?: number;
totalCount?: number;
}
@@ -96,41 +95,45 @@ function TicketClassDetectionPreviewSkeleton() {
export function TicketClassDetectionPreview({
ticketId,
videoId,
defectClassName,
displayName,
assignmentId,
totalCount,
}: TicketClassDetectionPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const visual = getDefectVisual(defectClassName);
const IssueIcon = visual.icon;
useEffect(() => {
setCurrentIndex(0);
}, [defectClassName]);
}, [assignmentId, videoId]);
const queryParams = useMemo(
() => ({
class_name: defectClassName,
skip: currentIndex,
limit: 1,
assignment_id: assignmentId,
sort: 'timestamp_asc',
}),
[currentIndex, defectClassName],
[assignmentId, currentIndex],
);
const detectionsQuery = useTicketClassDetectionsQuery(
const detectionsQuery = useTicketDetectionsQuery(
videoId ?? undefined,
queryParams,
);
const detectionResult = detectionsQuery.data;
const activeDetection = detectionResult?.items[0];
const detectionCount =
detectionResult?.total ?? (assignmentId ? 0 : (totalCount ?? 0));
const activeVisual = getDefectVisual(
activeDetection?.detection.class_name ?? '',
);
const ActiveIssueIcon = activeVisual.icon;
const activeDisplayName =
activeDetection?.detection.display_name ?? 'Detection';
const discardMutation = useDiscardDetectionMutation(
ticketId,
videoId ?? undefined,
defectClassName,
activeDetection?.detection.class_name,
);
const detectionResult = detectionsQuery.data;
const activeDetection = detectionResult?.items[0];
const detectionCount = detectionResult?.total ?? totalCount ?? 0;
useEffect(() => {
if (detectionCount === 0 && currentIndex !== 0) {
@@ -153,27 +156,22 @@ export function TicketClassDetectionPreview({
<span
className={cn(
'flex size-10 shrink-0 self-start items-center justify-center rounded-lg',
visual.cardClassName,
activeVisual.cardClassName,
)}
>
<IssueIcon className={cn('size-5', visual.colorClassName)} />
<ActiveIssueIcon
className={cn('size-5', activeVisual.colorClassName)}
/>
</span>
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3>{displayName}</h3>
<Badge
variant="secondary"
className={cn(
'rounded-lg',
visual.cardClassName,
visual.colorClassName,
)}
>
<h3>{activeDisplayName}</h3>
<Badge variant="secondary" className="rounded-lg">
{detectionCount} Detections
</Badge>
</div>
<p className="text-muted-foreground">
Review detections in ascending timestamp order.
Review all detections in ascending timestamp order.
</p>
</div>
</div>
@@ -258,7 +256,7 @@ export function TicketClassDetectionPreview({
className="min-w-0"
latitude={activeDetection.location.latitude}
longitude={activeDetection.location.longitude}
label={`${displayName} - Frame ${activeDetection.frame.number}`}
label={`${activeDetection.detection.display_name} - Frame ${activeDetection.frame.number}`}
title="Location on Map"
variant="plain"
aspectRatio="16 / 9"

View File

@@ -1,14 +1,9 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useEffect } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Component, ListChecks } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DEFECT_CLASS_STATUS_CONFIG } from '@/constants/defectClassStatus';
import { cn } from '@/lib/utils';
import type { TicketOverviewDetail } from '@/types';
import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
@@ -17,148 +12,59 @@ import { TicketClassDetectionPreview } from './TicketClassDetectionPreview';
interface TicketDefectClassTabsProps {
ticketId: string;
ticket?: TicketOverviewDetail;
assignmentId?: number;
}
export function TicketDefectClassTabs({
ticketId,
ticket,
assignmentId,
}: TicketDefectClassTabsProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const defectClassParam = searchParams.get('defect_class');
const defectClassesQuery = useTicketDefectClassesQuery(ticketId);
const defectClasses = useMemo(
() => defectClassesQuery.data?.defect_classes ?? [],
[defectClassesQuery.data?.defect_classes],
);
const [selectedClass, setSelectedClass] = useState<string>();
useEffect(() => {
const defectClasses = defectClassesQuery.data?.defect_classes ?? [];
if (!defectClasses.length) return;
const hasUrlClass = defectClasses.some(
(item) => item.class_name === defectClassParam,
);
if (hasUrlClass) {
setSelectedClass(defectClassParam ?? undefined);
return;
}
if (hasUrlClass) return;
const firstClass = defectClasses[0].class_name;
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('defect_class', firstClass);
setSelectedClass(firstClass);
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
}, [defectClassParam, defectClasses, pathname, router, searchParams]);
}, [
defectClassParam,
defectClassesQuery.data?.defect_classes,
pathname,
router,
searchParams,
]);
const handleClassChange = (className: string) => {
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('defect_class', className);
setSelectedClass(className);
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
};
const selectedIssue = ticket?.ai_result.detections_by_class.find(
(item) => item.class_name === selectedClass,
);
const selectedClassItem = defectClasses.find(
(item) => item.class_name === selectedClass,
);
const previewDisplayName =
selectedIssue?.display_name ??
selectedClassItem?.display_name ??
selectedClass ??
'Detection';
const previewVideoId = ticket?.video_id ?? defectClassesQuery.data?.video_id;
if (defectClassesQuery.isLoading) {
return (
<Card className="w-full rounded-lg">
<CardContent className="space-y-4 p-5">
<div className="h-5 w-24 animate-pulse rounded-lg bg-muted" />
<div className="flex flex-col gap-2">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="h-11 w-full animate-pulse rounded-lg bg-muted"
/>
))}
</div>
</CardContent>
</Card>
);
}
if (defectClassesQuery.isError || !defectClasses.length || !selectedClass) {
return null;
}
if (!previewVideoId) return null;
return (
<Tabs
value={selectedClass}
onValueChange={handleClassChange}
orientation="vertical"
className="w-full"
>
<Card className="w-full">
<CardContent className="grid gap-0 p-0 lg:grid-cols-[minmax(220px,280px)_1fr] lg:divide-x lg:divide-border">
<div className="space-y-4 px-5">
<div className="flex items-center gap-2">
<ListChecks className="size-5 shrink-0 text-primary" />
<h2 className="text-base font-semibold">Detected Issues</h2>
<Badge
variant="secondary"
className="min-w-5 min-h-5 justify-center rounded-full"
>
{defectClasses.length}
</Badge>
</div>
<TabsList className="flex h-auto w-full flex-col items-stretch gap-2 bg-transparent p-0">
{defectClasses.map((item) => {
const statusConfig =
DEFECT_CLASS_STATUS_CONFIG[item.assignment_status];
return (
<TabsTrigger
key={item.class_name}
value={item.class_name}
className={cn(
'group h-auto w-full flex-none items-center justify-between! gap-2 rounded-lg border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground shadow-none after:hidden',
'data-[state=active]:bg-muted data-[state=active]:text-foreground',
)}
>
<span className="min-w-0 truncate text-left font-medium">
{item.display_name}
</span>
<Badge
className={cn(
'shrink-0 rounded-full',
statusConfig.badgeClassName,
)}
>
<Component className="size-3" />
{statusConfig.label}
</Badge>
</TabsTrigger>
);
})}
</TabsList>
</div>
<div className="min-w-0 px-5">
<CardContent className="p-5">
<div className="min-w-0">
<TicketClassDetectionPreview
ticketId={ticketId}
videoId={previewVideoId}
defectClassName={selectedClass}
displayName={previewDisplayName}
totalCount={selectedIssue?.count}
assignmentId={assignmentId}
totalCount={ticket?.ai_result.detection_count}
/>
</div>
</CardContent>
</Card>
</Tabs>
);
}

View File

@@ -4,12 +4,19 @@ import { ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import type { TicketOverviewDetail } from '@/types';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { TicketDetail, TicketOverviewDetail } from '@/types';
import { AssignTicketAction } from './actions/AssignTicketAction';
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
export function TicketDetailHeader({
ticket,
classDetail,
}: {
ticket: TicketOverviewDetail;
classDetail?: TicketDetail;
}) {
const router = useRouter();
const ticketLabel = ticket.ticket_name || ticket.id;
@@ -22,10 +29,15 @@ export function TicketDetailHeader({
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="secondary"
onClick={() => router.push('/ticket')}
>
{classDetail?.assignment_status === 'under_review' ? (
<PermissionGuard permissions={PERMISSIONS.TICKET.REVIEW}>
<OpenRepairReviewAction ticket={classDetail} />
</PermissionGuard>
) : null}
<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

@@ -2,33 +2,24 @@
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { TicketDetail } from '@/types';
import type { TicketAssignmentSummary, 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';
import { RequestExtensionAction } from './actions/RequestExtensionAction';
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
interface TicketStatusActionsProps {
ticket: TicketDetail;
assignment?: TicketAssignmentSummary;
}
function getTicketAction(ticket: TicketDetail) {
if (ticket.assignment_status === 'unassigned') {
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={<NoTicketAction />}
>
<AssignTicketAction ticket={ticket} />
</PermissionGuard>
);
}
if (ticket.assignment_status === 'assigned') {
function getTicketAction(
ticket: TicketDetail,
assignment?: TicketAssignmentSummary,
) {
if (assignment && !assignment.is_complete) {
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN}
@@ -37,29 +28,46 @@ function getTicketAction(ticket: TicketDetail) {
permissions={PERMISSIONS.TICKET.WORK}
fallback={<NoTicketAction />}
>
<RequestExtensionAction ticket={ticket} />
<RequestExtensionAction
ticket={ticket}
assignmentId={assignment.id}
dueAt={assignment.due_at}
/>
</PermissionGuard>
}
>
<>
{ticket.assignment_status === 'assigned' ? (
<AssignedContractorAction ticket={ticket} />
<ReviewExtensionRequestAction ticket={ticket} />
) : null}
<ReviewExtensionRequestAction
ticket={ticket}
assignmentId={assignment.id}
/>
</>
</PermissionGuard>
);
}
if (ticket.assignment_status === 'under_review') {
if (ticket.assignment_status === 'unassigned') {
return null;
}
if (ticket.assignment_status === 'assigned') {
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.REVIEW}
permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={<NoTicketAction />}
>
<OpenRepairReviewAction ticket={ticket} />
<AssignedContractorAction ticket={ticket} />
</PermissionGuard>
);
}
if (ticket.assignment_status === 'under_review') {
return null;
}
if (
ticket.assignment_status === 'approved' ||
ticket.assignment_status === 'rejected'
@@ -70,6 +78,11 @@ function getTicketAction(ticket: TicketDetail) {
return <NoTicketAction />;
}
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
return <div className="space-y-3">{getTicketAction(ticket)}</div>;
export function TicketStatusActions({
ticket,
assignment,
}: TicketStatusActionsProps) {
const action = getTicketAction(ticket, assignment);
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

@@ -1,37 +1,24 @@
'use client';
import { useRouter } from 'next/navigation';
import { ArrowRight, ClipboardCheck } from 'lucide-react';
import { ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { TicketDetail } from '@/types';
import { ROUTES } from '@/utils/routes';
import { TicketActionCard } from './TicketActionCard';
export function OpenRepairReviewAction({ ticket }: { ticket: TicketDetail }) {
const router = useRouter();
return (
<TicketActionCard icon={ClipboardCheck} title="Review Submitted Repairs">
<div className="space-y-4">
<p className="text-sm leading-relaxed text-muted-foreground">
Review every submitted repair separately using its detection image,
location, and repair proof.
</p>
<Button
type="button"
className="w-full"
onClick={() =>
router.push(
ROUTES.TICKET_CLASS_REVIEW(ticket.id, ticket.defect_class),
)
router.push(ROUTES.TICKET_CLASS_REVIEW(ticket.id, ticket.defect_class))
}
>
Open Issue Review
<ArrowRight />
<ClipboardCheck />
Review Repairs
</Button>
</div>
</TicketActionCard>
);
}

View File

@@ -5,10 +5,22 @@ import type { TicketDetail } from '@/types';
import { TicketActionCard } from './TicketActionCard';
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
export function RequestExtensionAction({ ticket }: { ticket: TicketDetail }) {
export function RequestExtensionAction({
ticket,
assignmentId,
dueAt,
}: {
ticket: TicketDetail;
assignmentId: number;
dueAt: string | null;
}) {
return (
<TicketActionCard icon={Clock3} title="Request Extension">
<TicketExtensionRequestPanel ticket={ticket} />
<TicketExtensionRequestPanel
ticket={ticket}
assignmentId={assignmentId}
dueAt={dueAt}
/>
</TicketActionCard>
);
}

View File

@@ -16,10 +16,15 @@ const MAX_REASON_LENGTH = 300;
interface RequestExtensionFormProps {
ticket: TicketDetail;
assignmentId: number;
currentDueAt: string | null;
}
export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) {
const currentDueAt = ticket.worker?.due_at;
export function RequestExtensionForm({
ticket,
assignmentId,
currentDueAt,
}: RequestExtensionFormProps) {
const currentDueDate = useMemo(
() => (currentDueAt ? new Date(currentDueAt) : undefined),
[currentDueAt],
@@ -36,7 +41,6 @@ export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) {
);
const [requestedDueDate, setRequestedDueDate] = useState<Date>();
const [reason, setReason] = useState('');
const assignmentId = ticket.worker?.assignment_id;
const requestExtensionMutation = useRequestTicketExtensionMutation(
ticket.id,
assignmentId,
@@ -48,10 +52,7 @@ export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) {
(!currentDueDate || requestedDueDate.getTime() > currentDueDate.getTime()),
);
const canSubmit =
Boolean(assignmentId) &&
isRequestedDateValid &&
Boolean(reason.trim()) &&
!isPending;
isRequestedDateValid && Boolean(reason.trim()) && !isPending;
const handleSubmit = async () => {
if (!requestedDueDate || !canSubmit) return;

View File

@@ -77,16 +77,17 @@ function Requester({ request }: { request: TicketExtensionRequest }) {
function ExtensionReviewForm({
ticket,
assignmentId,
request,
}: {
ticket: TicketDetail;
assignmentId: number;
request: TicketExtensionRequest;
}) {
const [note, setNote] = useState('');
const [selectedAction, setSelectedAction] = useState<
'approve' | 'reject' | null
>(null);
const assignmentId = ticket.worker?.assignment_id as number;
const reviewMutation = useReviewTicketExtensionMutation(
ticket.id,
assignmentId,
@@ -197,16 +198,17 @@ function ExtensionReviewForm({
export function ReviewExtensionRequestAction({
ticket,
assignmentId,
}: {
ticket: TicketDetail;
assignmentId: number;
}) {
const assignmentId = ticket.worker?.assignment_id;
const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id,
assignmentId,
);
if (!assignmentId || extensionRequestQuery.isError) {
if (extensionRequestQuery.isError) {
return null;
}
@@ -227,5 +229,11 @@ export function ReviewExtensionRequestAction({
if (!pendingRequest) return null;
return <ExtensionReviewForm ticket={ticket} request={pendingRequest} />;
return (
<ExtensionReviewForm
ticket={ticket}
assignmentId={assignmentId}
request={pendingRequest}
/>
);
}

View File

@@ -187,23 +187,18 @@ function ExtensionRequestDetails({
export function TicketExtensionRequestPanel({
ticket,
assignmentId,
dueAt,
}: {
ticket: TicketDetail;
assignmentId: number;
dueAt: string | null;
}) {
const assignmentId = ticket.worker?.assignment_id;
const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id,
assignmentId,
);
if (!assignmentId) {
return (
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
No active assignment is available for this ticket.
</div>
);
}
if (extensionRequestQuery.isLoading) {
return (
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
@@ -237,7 +232,13 @@ export function TicketExtensionRequestPanel({
: null;
if (!latestRequest) {
return <RequestExtensionForm ticket={ticket} />;
return (
<RequestExtensionForm
ticket={ticket}
assignmentId={assignmentId}
currentDueAt={dueAt}
/>
);
}
return <ExtensionRequestDetails request={latestRequest} />;

View File

@@ -1,5 +1,6 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
@@ -16,11 +17,13 @@ import {
} from './components/TicketClassDetailSkeleton';
import { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketAssignmentsCard } from './components/TicketAssignmentsCard';
import { TicketOverviewCard } from './components/TicketOverviewCard';
import { TicketStatusActions } from './components/TicketStatusActions';
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
import {
useTicketAssignmentsQuery,
useTicketClassDetailQuery,
useTicketOverviewQuery,
} from '../hooks/useTicketQueries';
@@ -30,11 +33,21 @@ export default function TicketDetailPage() {
const searchParams = useSearchParams();
const { ticketId } = useParams() as { ticketId: string };
const defectClass = searchParams.get('defect_class') ?? undefined;
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>();
const overviewQuery = useTicketOverviewQuery(ticketId);
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
const overview = overviewQuery.data;
const assignmentsQuery = useTicketAssignmentsQuery(
ticketId,
overview?.assignments,
);
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
const classDetail = classDetailQuery.data;
const assignments = assignmentsQuery.data?.items ?? [];
const activeAssignmentId = selectedAssignmentId ?? assignments[0]?.id;
const activeAssignment = assignments.find(
(assignment) => assignment.id === activeAssignmentId,
);
const isClassDetailLoading = Boolean(
defectClass && classDetailQuery.isLoading,
);
@@ -58,33 +71,48 @@ export default function TicketDetailPage() {
}
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">
<main className="relative z-10 min-w-0 max-w-full 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">
<div className="xl:shrink-0">
{overview ? (
<TicketDetailHeader ticket={overview} />
<TicketDetailHeader ticket={overview} classDetail={classDetail} />
) : (
<TicketOverviewHeaderSkeleton />
)}
</div>
<div className="grid items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)] xl:items-stretch xl:overflow-hidden">
<div className="space-y-5 xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
<div className="grid min-w-0 items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)] xl:items-stretch xl:overflow-hidden">
<div className="min-w-0 space-y-5 xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
{overview ? (
<TicketOverviewCard ticket={overview} />
) : (
<TicketOverviewCardSkeleton />
)}
<TicketDefectClassTabs ticketId={ticketId} ticket={overview} />
{overview ? (
<TicketAssignmentsCard
ticketId={ticketId}
initialAssignments={overview.assignments}
selectedAssignmentId={activeAssignmentId}
onAssignmentChange={setSelectedAssignmentId}
/>
) : null}
<TicketDefectClassTabs
ticketId={ticketId}
ticket={overview}
assignmentId={activeAssignmentId}
/>
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
{!isClassDetailLoading && classDetail ? (
<TicketHistoryCard ticket={classDetail} />
) : null}
</div>
<div className="space-y-3 self-start xl:min-h-0 xl:self-stretch xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
<div className="min-w-0 space-y-3 self-start xl:min-h-0 xl:self-stretch xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
{!isClassDetailLoading && classDetail ? (
<TicketStatusActions ticket={classDetail} />
<TicketStatusActions
ticket={classDetail}
assignment={activeAssignment}
/>
) : null}
</div>
</div>

View File

@@ -32,6 +32,9 @@ export function useTicketDetailEvents(
if (!ticketId) return;
queryClient.refetchQueries({ queryKey: ticketKeys.overview(ticketId) });
queryClient.refetchQueries({
queryKey: ticketKeys.assignments(ticketId),
});
if (defectClassRef.current) {
queryClient.refetchQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClassRef.current),

View File

@@ -13,11 +13,13 @@ import { detectionService, ticketService, videoService } from '@/services/api';
import type {
AssignTicketPayload,
CloseTicketPayload,
DetectionCoordinatesParams,
DiscardDetectionPayload,
RequestTicketExtensionPayload,
ReviewDetectionRepairProofPayload,
ReviewTicketExtensionPayload,
TicketDetail,
TicketAssignmentSummary,
TicketListParams,
DetectionProofStatus,
VideoDetectionsParams,
@@ -91,7 +93,26 @@ export function useTicketDefectClassesQuery(ticketId: string | undefined) {
});
}
export function useTicketClassDetectionsQuery(
export function useTicketAssignmentsQuery(
ticketId: string | undefined,
initialAssignments?: TicketAssignmentSummary[],
) {
return useQuery({
queryKey: ticketKeys.assignments(ticketId ?? ''),
queryFn: () => ticketService.getTicketAssignments(ticketId as string),
enabled: Boolean(ticketId),
initialData:
initialAssignments !== undefined
? {
items: initialAssignments,
total: initialAssignments.length,
}
: undefined,
staleTime: 30 * 1000,
});
}
export function useTicketDetectionsQuery(
videoId: string | undefined,
params: VideoDetectionsParams,
) {
@@ -99,12 +120,14 @@ export function useTicketClassDetectionsQuery(
() => ({
skip: params.skip ?? 0,
limit: params.limit ?? 1,
assignment_id: params.assignment_id,
class_name: params.class_name,
min_confidence: params.min_confidence,
sort: params.sort ?? 'timestamp_asc',
search: params.search,
}),
[
params.assignment_id,
params.class_name,
params.limit,
params.min_confidence,
@@ -118,10 +141,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,
@@ -155,7 +222,7 @@ export function useTicketClassReviewDetectionsQuery(
export function useDiscardDetectionMutation(
ticketId: string,
videoId: string | undefined,
defectClass: string,
defectClass?: string,
) {
const queryClient = useQueryClient();
@@ -179,13 +246,20 @@ export function useDiscardDetectionMutation(
queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId),
}),
...(defectClass
? [
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass),
}),
]
: []),
queryClient.invalidateQueries({
queryKey: ticketKeys.defectClasses(ticketId),
}),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
}),
]);
},
onError: () => toast.error('Failed to discard detection'),
@@ -276,19 +350,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'),
});
@@ -320,6 +405,9 @@ export function useRequestTicketExtensionMutation(
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to request an extension'),
@@ -347,6 +435,9 @@ export function useReviewTicketExtensionMutation(
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to review extension request'),

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,
@@ -26,6 +39,8 @@ export const ticketKeys = {
] as const,
defectClasses: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
assignments: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'assignments'] as const,
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
[
...ticketKeys.details(),

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

@@ -9,6 +9,8 @@ import type {
ReviewTicketExtensionPayload,
SseTokenResponse,
TicketDetail,
TicketAssignmentSummary,
TicketAssignmentsResponse,
TicketExtensionRequestsResponse,
TicketOverviewDetail,
TicketDefectClassesResponse,
@@ -16,6 +18,14 @@ import type {
TicketListResponse,
} from '@/types';
type TicketAssignmentsApiResponse =
| TicketAssignmentSummary[]
| TicketAssignmentsResponse
| {
assignments: TicketAssignmentSummary[];
total?: number;
};
export const ticketService = {
getTickets: async (
params?: TicketListParams,
@@ -80,14 +90,36 @@ export const ticketService = {
assignTicket: async (
ticketId: string,
payload: AssignTicketPayload,
): Promise<TicketDetail> => {
const response = await axiosClient.post<TicketDetail>(
API_ROUTES.TICKETS.ASSIGN(ticketId),
): Promise<TicketAssignmentSummary> => {
const response = await axiosClient.post<TicketAssignmentSummary>(
API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
payload,
);
return response.data;
},
getTicketAssignments: async (
ticketId: string,
): Promise<TicketAssignmentsResponse> => {
const response = await axiosClient.get<TicketAssignmentsApiResponse>(
API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
);
const data = response.data;
if (Array.isArray(data)) {
return { items: data, total: data.length };
}
if ('assignments' in data) {
return {
items: data.assignments,
total: data.total ?? data.assignments.length,
};
}
return data;
},
requestTicketExtension: async (
ticketId: string,
assignmentId: number,

View File

@@ -73,12 +73,16 @@ export const videoService = {
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 1,
assignment_id: params?.assignment_id,
class_name: params?.class_name,
proof_status: params?.proof_status,
min_confidence: params?.min_confidence,
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;
@@ -78,6 +110,7 @@ export type DetectionResultLog = DetectionResultItem & {
export type VideoDetectionsParams = {
skip?: number;
limit?: number;
assignment_id?: number;
class_name?: string | string[];
proof_status?: DetectionProofStatus;
min_confidence?: number;

View File

@@ -22,11 +22,21 @@ export interface AssignableTicketUsersResponse {
total: number;
}
export interface TicketAssignmentCriteria {
class_names?: string[] | null;
start_lat?: number | null;
start_lng?: number | null;
end_lat?: number | null;
end_lng?: number | null;
min_confidence?: number | null;
}
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

@@ -1,5 +1,6 @@
import type { TicketDefectClassTicketStatus } from './defect-class';
import type { TicketStatus } from './status';
import type { TicketAssignmentCriteria } from './actions';
export interface TicketActor {
user_id: number | null;
@@ -114,6 +115,28 @@ export interface TicketDetectionClassCount {
count: number;
}
export interface TicketAssignmentSummary {
id: number;
assigned_to_user_id: number;
assigned_to_name: string | null;
assigned_to_email?: string | null;
assigned_to_avatar_url?: string | null;
defect_class: string | null;
criteria: TicketAssignmentCriteria | null;
item_count: number;
approved_detections: number;
pending_detections: number;
not_submitted_detections: number;
is_complete: boolean;
due_at: string | null;
note?: string | null;
}
export interface TicketAssignmentsResponse {
items: TicketAssignmentSummary[];
total: number;
}
export interface TicketOverviewDetail {
id: string;
ticket_name?: string | null;
@@ -134,6 +157,7 @@ export interface TicketOverviewDetail {
available_defect_classes: string[];
detections_by_class: TicketDetectionClassCount[];
};
assignments?: TicketAssignmentSummary[];
timestamps?: TicketTimestamps | null;
}

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;