Compare commits
8 Commits
dev
...
e894676d3b
| Author | SHA1 | Date | |
|---|---|---|---|
| e894676d3b | |||
| 31839d1c70 | |||
| e8f2955acf | |||
| 5e66b70611 | |||
| 7ffe3f0708 | |||
| 8e8803396d | |||
| 0601322361 | |||
| 20f611f35a |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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="© 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
161
src/app/(modules)/ticket/[ticketId]/assign/page.tsx
Normal file
161
src/app/(modules)/ticket/[ticketId]/assign/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,52 +95,62 @@ 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 confirmedDetectionCount = detectionResult?.total;
|
||||
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) {
|
||||
if (confirmedDetectionCount === undefined) return;
|
||||
|
||||
if (confirmedDetectionCount === 0 && currentIndex !== 0) {
|
||||
setCurrentIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (detectionCount > 0 && currentIndex > detectionCount - 1) {
|
||||
setCurrentIndex(detectionCount - 1);
|
||||
if (
|
||||
confirmedDetectionCount > 0 &&
|
||||
currentIndex > confirmedDetectionCount - 1
|
||||
) {
|
||||
setCurrentIndex(confirmedDetectionCount - 1);
|
||||
}
|
||||
}, [currentIndex, detectionCount]);
|
||||
}, [confirmedDetectionCount, currentIndex]);
|
||||
|
||||
const isNavigationDisabled =
|
||||
detectionCount === 0 || detectionsQuery.isLoading;
|
||||
@@ -153,27 +162,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 +262,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"
|
||||
|
||||
@@ -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">
|
||||
<TicketClassDetectionPreview
|
||||
ticketId={ticketId}
|
||||
videoId={previewVideoId}
|
||||
defectClassName={selectedClass}
|
||||
displayName={previewDisplayName}
|
||||
totalCount={selectedIssue?.count}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
<Card className="w-full">
|
||||
<CardContent className="p-5">
|
||||
<div className="min-w-0">
|
||||
<TicketClassDetectionPreview
|
||||
ticketId={ticketId}
|
||||
videoId={previewVideoId}
|
||||
assignmentId={assignmentId}
|
||||
totalCount={ticket?.ai_result.detection_count}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
MapPin,
|
||||
Monitor,
|
||||
UserRound,
|
||||
Play
|
||||
Play,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -172,79 +172,84 @@ export function TicketOverviewCard({
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="size-5 text-primary" />
|
||||
AI Detection Summary
|
||||
</CardTitle>
|
||||
{analysisVideoId ? (
|
||||
<CardAction>
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setIsAnalysisOpen(true)}
|
||||
>
|
||||
<Play className="mr-2 size-4" />
|
||||
View Video
|
||||
</Button>
|
||||
</PermissionGuard>
|
||||
</CardAction>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<SummaryCard
|
||||
label="Total Detections"
|
||||
value={ticket.ai_result.detection_count}
|
||||
colorClassName="text-violet-600"
|
||||
cardClassName="bg-violet-500/8"
|
||||
/>
|
||||
{ticket.ai_result.detections_by_class.map((item) => {
|
||||
const visual = getDefectVisual(item.class_name);
|
||||
return (
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="size-5 text-primary" />
|
||||
AI Detection Summary
|
||||
</CardTitle>
|
||||
{analysisVideoId ? (
|
||||
<CardAction>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setIsAnalysisOpen(true)}
|
||||
>
|
||||
<Play className="mr-2 size-4" />
|
||||
View Video
|
||||
</Button>
|
||||
</CardAction>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<SummaryCard
|
||||
key={item.class_name}
|
||||
label={item.display_name}
|
||||
value={item.count}
|
||||
colorClassName={visual.colorClassName}
|
||||
cardClassName={visual.cardClassName}
|
||||
label="Total Detections"
|
||||
value={ticket.ai_result.detection_count}
|
||||
colorClassName="text-violet-600"
|
||||
cardClassName="bg-violet-500/8"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasVideoMetrics ? (
|
||||
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3">
|
||||
{videoMetadata?.fps ? (
|
||||
<VideoMetric icon={Gauge} label={`${videoMetadata.fps} FPS`} />
|
||||
{ticket.ai_result.detections_by_class.map((item) => {
|
||||
const visual = getDefectVisual(item.class_name);
|
||||
return (
|
||||
<SummaryCard
|
||||
key={item.class_name}
|
||||
label={item.display_name}
|
||||
value={item.count}
|
||||
colorClassName={visual.colorClassName}
|
||||
cardClassName={visual.cardClassName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasVideoMetrics ? (
|
||||
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3">
|
||||
{videoMetadata?.fps ? (
|
||||
<VideoMetric
|
||||
icon={Gauge}
|
||||
label={`${videoMetadata.fps} FPS`}
|
||||
/>
|
||||
) : null}
|
||||
{duration ? (
|
||||
<VideoMetric icon={Clock3} label={`${duration} Duration`} />
|
||||
) : null}
|
||||
{videoMetadata?.resolution?.label ? (
|
||||
<VideoMetric
|
||||
icon={Monitor}
|
||||
label={`${videoMetadata.resolution.label} Resolution`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{duration ? (
|
||||
<VideoMetric icon={Clock3} label={`${duration} Duration`} />
|
||||
) : null}
|
||||
{videoMetadata?.resolution?.label ? (
|
||||
<VideoMetric
|
||||
icon={Monitor}
|
||||
label={`${videoMetadata.resolution.label} Resolution`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={isAnalysisOpen} onOpenChange={setIsAnalysisOpen}>
|
||||
<DialogContent className="sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{analysisTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Video analysis with annotation overlay
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<TicketInlineAnalysisVideo ticket={ticket} />
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={isAnalysisOpen} onOpenChange={setIsAnalysisOpen}>
|
||||
<DialogContent className="sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{analysisTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Video analysis with annotation overlay
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<TicketInlineAnalysisVideo ticket={ticket} />
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</PermissionGuard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<AssignedContractorAction ticket={ticket} />
|
||||
<ReviewExtensionRequestAction ticket={ticket} />
|
||||
{ticket.assignment_status === 'assigned' ? (
|
||||
<AssignedContractorAction 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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<Send />
|
||||
Assign Ticket
|
||||
</Button>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => router.push(ROUTES.TICKET_ASSIGN(ticketId))}
|
||||
>
|
||||
<Users />
|
||||
Assign Ticket
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
>
|
||||
Open Issue Review
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
router.push(ROUTES.TICKET_CLASS_REVIEW(ticket.id, ticket.defect_class))
|
||||
}
|
||||
>
|
||||
<ClipboardCheck />
|
||||
Review Repairs
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||
}),
|
||||
...(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'),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user