feat(ticket): add issue-by-issue repair review page
This commit is contained in:
@@ -0,0 +1,179 @@
|
|||||||
|
import { CalendarCheck2, CheckCircle2, ImageIcon, MapPin } from 'lucide-react';
|
||||||
|
|
||||||
|
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { DetectionResultItem } from '@/types';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
|
import { RepairProofGallery } from '../../../components/repair/RepairProofGallery';
|
||||||
|
import { formatVideoTimestamp, type IssueReviewStatus } from './reviewTypes';
|
||||||
|
|
||||||
|
const REVIEW_STATUS_LABEL: Record<IssueReviewStatus, string> = {
|
||||||
|
not_submitted: 'Awaiting Repair',
|
||||||
|
pending: 'Under Review',
|
||||||
|
approved: 'Approved',
|
||||||
|
rejected: 'Rejected',
|
||||||
|
};
|
||||||
|
|
||||||
|
const REVIEW_STATUS_CLASS: Record<IssueReviewStatus, string> = {
|
||||||
|
not_submitted: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-300',
|
||||||
|
pending: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||||
|
approved: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||||
|
rejected: 'bg-destructive/10 text-destructive',
|
||||||
|
};
|
||||||
|
|
||||||
|
function MetadataItem({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 space-y-1 px-4 first:pl-0 last:pr-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<div className="truncate text-sm font-semibold text-foreground">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssueEvidencePanelProps {
|
||||||
|
detection: DetectionResultItem;
|
||||||
|
issueNumber: number;
|
||||||
|
videoWidth: number;
|
||||||
|
videoHeight: number;
|
||||||
|
reviewStatus: IssueReviewStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IssueEvidencePanel({
|
||||||
|
detection,
|
||||||
|
issueNumber,
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
|
reviewStatus,
|
||||||
|
}: IssueEvidencePanelProps) {
|
||||||
|
const repairProof = detection.repair_proof;
|
||||||
|
const displayName = detection.detection.display_name;
|
||||||
|
const confidence = Math.round(detection.detection.confidence * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{displayName} #{issueNumber}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(REVIEW_STATUS_CLASS[reviewStatus])}
|
||||||
|
>
|
||||||
|
{REVIEW_STATUS_LABEL[reviewStatus]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 divide-x sm:grid-cols-4">
|
||||||
|
<MetadataItem
|
||||||
|
label="Video Timestamp"
|
||||||
|
value={formatVideoTimestamp(detection.frame.timestamp_seconds)}
|
||||||
|
/>
|
||||||
|
<MetadataItem label="Frame" value={detection.frame.number} />
|
||||||
|
<MetadataItem label="Confidence" value={`${confidence}%`} />
|
||||||
|
<MetadataItem label="Issue ID" value={detection.detection.id} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid items-start gap-4 2xl:grid-cols-2">
|
||||||
|
<section className="min-w-0 space-y-2">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<ImageIcon className="size-4 text-primary" />
|
||||||
|
Detection Image (Before)
|
||||||
|
</p>
|
||||||
|
<AnnotatedDetectionImage
|
||||||
|
detection={detection}
|
||||||
|
videoWidth={videoWidth}
|
||||||
|
videoHeight={videoHeight}
|
||||||
|
aspectRatio="16 / 9"
|
||||||
|
enableLightbox
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="min-w-0 space-y-2">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<MapPin className="size-4 text-primary" />
|
||||||
|
Detection Location
|
||||||
|
</p>
|
||||||
|
<DetectionLocationMap
|
||||||
|
latitude={detection.location.latitude}
|
||||||
|
longitude={detection.location.longitude}
|
||||||
|
label={`${displayName} #${issueNumber} - Frame ${detection.frame.number}`}
|
||||||
|
title=""
|
||||||
|
variant="plain"
|
||||||
|
aspectRatio="16 / 9"
|
||||||
|
showCoordinates={false}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<CalendarCheck2 className="size-4 text-primary" />
|
||||||
|
Repair Proof (After Repair)
|
||||||
|
</p>
|
||||||
|
{repairProof?.submitted ? (
|
||||||
|
<Badge className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||||
|
<CheckCircle2 />
|
||||||
|
Repair Submitted
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-5">
|
||||||
|
{!repairProof?.submitted ? (
|
||||||
|
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Repair proof is not submitted yet.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
This issue cannot be reviewed until repair evidence is
|
||||||
|
available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{repairProof.submitted_at ? (
|
||||||
|
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-4 py-3">
|
||||||
|
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-300">
|
||||||
|
Submitted {formatDate(repairProof.submitted_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<RepairProofGallery
|
||||||
|
imageUrls={repairProof.proof_image_urls}
|
||||||
|
maxVisibleImages={4}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 rounded-lg border bg-card p-4 shadow-xs">
|
||||||
|
<p className="text-xs font-semibold text-foreground">
|
||||||
|
Repair Notes
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
|
||||||
|
{repairProof.notes?.trim() ||
|
||||||
|
'No repair notes were provided.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
CheckCircle2,
|
||||||
|
ClipboardCheck,
|
||||||
|
Info,
|
||||||
|
Loader2,
|
||||||
|
X,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
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 { cn } from '@/lib/utils';
|
||||||
|
import type {
|
||||||
|
DetectionResultItem,
|
||||||
|
ReviewDetectionRepairProofPayload,
|
||||||
|
} from '@/types';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
|
import type { IssueReviewStatus } from './reviewTypes';
|
||||||
|
|
||||||
|
type ReviewDecision = ReviewDetectionRepairProofPayload['action'];
|
||||||
|
|
||||||
|
interface IssueReviewActionProps {
|
||||||
|
detection: DetectionResultItem;
|
||||||
|
issueLabel: string;
|
||||||
|
status: IssueReviewStatus;
|
||||||
|
isPending: boolean;
|
||||||
|
onSave: (payload: ReviewDetectionRepairProofPayload) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IssueReviewAction({
|
||||||
|
detection,
|
||||||
|
issueLabel,
|
||||||
|
status,
|
||||||
|
isPending,
|
||||||
|
onSave,
|
||||||
|
}: IssueReviewActionProps) {
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const [decision, setDecision] = useState<ReviewDecision>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setComment('');
|
||||||
|
setDecision(undefined);
|
||||||
|
}, [detection.detection.id]);
|
||||||
|
|
||||||
|
if (status === 'not_submitted') {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ClipboardCheck className="size-5 text-primary" />
|
||||||
|
Review & Action
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-lg border border-dashed bg-muted/30 px-4 py-8 text-center">
|
||||||
|
<Info className="mx-auto size-6 text-muted-foreground" />
|
||||||
|
<p className="mt-3 text-sm font-medium">Repair proof required</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{issueLabel} is not ready for review.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'approved' || status === 'rejected') {
|
||||||
|
const isApproved = status === 'approved';
|
||||||
|
const repairProof = detection.repair_proof;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ClipboardCheck className="size-5 text-primary" />
|
||||||
|
Review Result
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border px-4 py-5 text-center',
|
||||||
|
isApproved
|
||||||
|
? 'border-emerald-500/20 bg-emerald-500/5'
|
||||||
|
: 'border-destructive/20 bg-destructive/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isApproved ? (
|
||||||
|
<CheckCircle2 className="mx-auto size-8 text-emerald-600" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="mx-auto size-8 text-destructive" />
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
'mt-3',
|
||||||
|
isApproved
|
||||||
|
? 'bg-emerald-500/10 text-emerald-600'
|
||||||
|
: 'bg-destructive/10 text-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isApproved ? 'Approved' : 'Rejected'}
|
||||||
|
</Badge>
|
||||||
|
{repairProof?.reviewed_by_name || repairProof?.reviewed_at ? (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{repairProof.reviewed_by_name
|
||||||
|
? `By ${repairProof.reviewed_by_name}`
|
||||||
|
: 'Reviewed'}
|
||||||
|
{repairProof.reviewed_at
|
||||||
|
? ` on ${formatDate(repairProof.reviewed_at)}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{repairProof?.review_comment ? (
|
||||||
|
<div className="space-y-1.5 rounded-lg bg-muted/30 p-3">
|
||||||
|
<p className="text-xs font-semibold">Review Comment</p>
|
||||||
|
<p className="text-sm whitespace-pre-wrap text-muted-foreground">
|
||||||
|
{repairProof.review_comment}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitDecision = async (nextDecision: ReviewDecision) => {
|
||||||
|
setDecision(nextDecision);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
action: nextDecision,
|
||||||
|
comment: comment.trim() || undefined,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// The mutation owns user-facing error feedback.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ClipboardCheck className="size-5 text-primary" />
|
||||||
|
Review & Action
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="review-comment">Comment (Optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="review-comment"
|
||||||
|
value={comment}
|
||||||
|
maxLength={4000}
|
||||||
|
rows={5}
|
||||||
|
disabled={isPending}
|
||||||
|
onChange={(event) => setComment(event.target.value)}
|
||||||
|
placeholder="Enter your review comments..."
|
||||||
|
/>
|
||||||
|
<p className="text-right text-xs text-muted-foreground">
|
||||||
|
{comment.length} / 4000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Decision</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => void submitDecision('approve')}
|
||||||
|
className={cn(
|
||||||
|
decision === 'approve' &&
|
||||||
|
'border-emerald-500 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPending && decision === 'approve' ? (
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check />
|
||||||
|
)}
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => void submitDecision('reject')}
|
||||||
|
className={cn(
|
||||||
|
decision === 'reject' &&
|
||||||
|
'border-destructive bg-destructive/10 text-destructive hover:bg-destructive/15',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPending && decision === 'reject' ? (
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<X />
|
||||||
|
)}
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
import type { ProofStatusFilter } from './reviewTypes';
|
||||||
|
|
||||||
|
const PROOF_STATUS_FILTERS: Array<{
|
||||||
|
value: ProofStatusFilter;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
{ value: 'approved', label: 'Approved' },
|
||||||
|
{ value: 'rejected', label: 'Rejected' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface IssueReviewHeaderProps {
|
||||||
|
ticketLabel: string;
|
||||||
|
defectLabel: string;
|
||||||
|
currentIndex: number;
|
||||||
|
total: number;
|
||||||
|
proofStatus: ProofStatusFilter;
|
||||||
|
onBack: () => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onProofStatusChange: (status: ProofStatusFilter) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IssueReviewHeader({
|
||||||
|
ticketLabel,
|
||||||
|
defectLabel,
|
||||||
|
currentIndex,
|
||||||
|
total,
|
||||||
|
proofStatus,
|
||||||
|
onBack,
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
onProofStatusChange,
|
||||||
|
}: IssueReviewHeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
onClick={onBack}
|
||||||
|
aria-label="Back to ticket"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h1 className="truncate text-2xl font-semibold tracking-tight">
|
||||||
|
{defectLabel} Review
|
||||||
|
</h1>
|
||||||
|
<Badge variant="secondary">{ticketLabel}</Badge>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex max-w-full flex-nowrap items-center gap-1.5 overflow-x-auto pt-1 pb-1"
|
||||||
|
aria-label="Filter issues by repair proof status"
|
||||||
|
>
|
||||||
|
{PROOF_STATUS_FILTERS.map((filter) => {
|
||||||
|
const isActive = proofStatus === filter.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={filter.value}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={isActive ? 'default' : 'outline'}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
onClick={() => onProofStatusChange(filter.value)}
|
||||||
|
className="h-7 shrink-0 rounded-full px-3 text-xs"
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 self-end lg:self-auto">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={total === 0 || currentIndex <= 0}
|
||||||
|
onClick={onPrevious}
|
||||||
|
aria-label="Previous issue"
|
||||||
|
>
|
||||||
|
<ChevronLeft />
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-16 text-center text-sm font-semibold">
|
||||||
|
{total === 0 ? '0 of 0' : `${currentIndex + 1} of ${total}`}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={total === 0 || currentIndex >= total - 1}
|
||||||
|
onClick={onNext}
|
||||||
|
aria-label="Next issue"
|
||||||
|
>
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Clock3,
|
||||||
|
Hourglass,
|
||||||
|
ListChecks,
|
||||||
|
Wrench,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { DetectionResultItem } from '@/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatVideoTimestamp,
|
||||||
|
getIssueId,
|
||||||
|
getIssueReviewStatus,
|
||||||
|
type IssueReviewStatus,
|
||||||
|
} from './reviewTypes';
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<
|
||||||
|
IssueReviewStatus,
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
icon: typeof Hourglass;
|
||||||
|
className: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
not_submitted: {
|
||||||
|
label: 'Awaiting Repair',
|
||||||
|
icon: Wrench,
|
||||||
|
className: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-300',
|
||||||
|
},
|
||||||
|
pending: {
|
||||||
|
label: 'Under Review',
|
||||||
|
icon: Hourglass,
|
||||||
|
className: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||||
|
},
|
||||||
|
approved: {
|
||||||
|
label: 'Approved',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
className: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||||
|
},
|
||||||
|
rejected: {
|
||||||
|
label: 'Rejected',
|
||||||
|
icon: XCircle,
|
||||||
|
className: 'bg-destructive/10 text-destructive',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IssueReviewQueueProps {
|
||||||
|
detections: DetectionResultItem[];
|
||||||
|
totalCount: number;
|
||||||
|
selectedIssueId?: string;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
isFetchingNextPage: boolean;
|
||||||
|
onSelect: (issueId: string) => void;
|
||||||
|
onLoadMore: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IssueReviewQueue({
|
||||||
|
detections,
|
||||||
|
totalCount,
|
||||||
|
selectedIssueId,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
onSelect,
|
||||||
|
onLoadMore,
|
||||||
|
}: IssueReviewQueueProps) {
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
|
const defectLabel = detections[0]?.detection.display_name ?? 'Detected';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const target = loadMoreRef.current;
|
||||||
|
if (!target || !hasNextPage || isFetchingNextPage) return;
|
||||||
|
|
||||||
|
const root = target.closest<HTMLElement>(
|
||||||
|
'[data-slot="scroll-area-viewport"]',
|
||||||
|
);
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) onLoadMore();
|
||||||
|
},
|
||||||
|
{ root, rootMargin: '160px 0px' },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(target);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasNextPage, isFetchingNextPage, onLoadMore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden xl:h-full xl:min-h-0">
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ListChecks className="size-5 text-primary" />
|
||||||
|
{defectLabel} Issues
|
||||||
|
<Badge variant="secondary" className="ml-auto rounded-full">
|
||||||
|
{totalCount}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="min-h-0 flex-1 p-2">
|
||||||
|
<ScrollArea className="h-112 pr-2 xl:h-full">
|
||||||
|
<div className="space-y-2 p-1">
|
||||||
|
{detections.map((detection, index) => {
|
||||||
|
const issueId = getIssueId(detection);
|
||||||
|
const status = getIssueReviewStatus(detection);
|
||||||
|
const config = STATUS_CONFIG[status];
|
||||||
|
const StatusIcon = config.icon;
|
||||||
|
const isSelected = issueId === selectedIssueId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={detection.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(issueId)}
|
||||||
|
aria-current={isSelected ? 'true' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg border p-3 text-left transition-colors',
|
||||||
|
'hover:border-primary/40 hover:bg-muted/50',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/6 shadow-sm'
|
||||||
|
: 'border-border bg-card',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<p className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||||
|
{defectLabel} #{index + 1}
|
||||||
|
</p>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{defectLabel} #{index + 1}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<span className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Clock3 className="size-3.5" />
|
||||||
|
{formatVideoTimestamp(
|
||||||
|
detection.frame.timestamp_seconds,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn('shrink-0 px-1.5', config.className)}
|
||||||
|
>
|
||||||
|
<StatusIcon />
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
<span aria-hidden="true">|</span>
|
||||||
|
<span>Frame {detection.frame.number}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div ref={loadMoreRef} className="space-y-2 py-1">
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<>
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</>
|
||||||
|
) : hasNextPage ? (
|
||||||
|
<p className="py-3 text-center text-xs text-muted-foreground">
|
||||||
|
Scroll to load more issues
|
||||||
|
</p>
|
||||||
|
) : detections.length > 0 ? (
|
||||||
|
<p className="py-3 text-center text-xs text-muted-foreground">
|
||||||
|
All {totalCount} issues loaded
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export function IssueReviewSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-56" />
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-9 w-32" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-5 xl:grid-cols-[280px_minmax(0,1fr)_330px]">
|
||||||
|
{[280, 600, 330].map((width) => (
|
||||||
|
<Card key={width}>
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Array.from({ length: 4 }, (_, index) => (
|
||||||
|
<Skeleton key={index} className="h-20 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { DetectionProofStatus, DetectionResultItem } from '@/types';
|
||||||
|
|
||||||
|
export type ProofStatusFilter =
|
||||||
|
| 'all'
|
||||||
|
| Extract<DetectionProofStatus, 'pending' | 'approved' | 'rejected'>;
|
||||||
|
|
||||||
|
export type IssueReviewStatus =
|
||||||
|
| 'not_submitted'
|
||||||
|
| 'pending'
|
||||||
|
| 'approved'
|
||||||
|
| 'rejected';
|
||||||
|
|
||||||
|
export function getIssueId(detection: DetectionResultItem) {
|
||||||
|
return String(detection.detection.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIssueReviewStatus(
|
||||||
|
detection: DetectionResultItem,
|
||||||
|
): IssueReviewStatus {
|
||||||
|
const repairProof = detection.repair_proof;
|
||||||
|
if (!repairProof?.submitted) return 'not_submitted';
|
||||||
|
|
||||||
|
return repairProof.review_status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatVideoTimestamp(totalSeconds: number) {
|
||||||
|
const seconds = Math.max(0, Math.floor(totalSeconds));
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainder = seconds % 60;
|
||||||
|
|
||||||
|
return `${minutes}:${remainder.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
useParams,
|
||||||
|
usePathname,
|
||||||
|
useRouter,
|
||||||
|
useSearchParams,
|
||||||
|
} from 'next/navigation';
|
||||||
|
import { AlertTriangle, ClipboardCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionGuard } from '@/guards';
|
||||||
|
import { ROUTES } from '@/utils/routes';
|
||||||
|
import type { ReviewDetectionRepairProofPayload } from '@/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
useReviewDetectionRepairProofMutation,
|
||||||
|
useTicketClassReviewDetectionsQuery,
|
||||||
|
useTicketOverviewQuery,
|
||||||
|
} from '../../../hooks/useTicketQueries';
|
||||||
|
import { IssueEvidencePanel } from './components/IssueEvidencePanel';
|
||||||
|
import { IssueReviewAction } from './components/IssueReviewAction';
|
||||||
|
import { IssueReviewHeader } from './components/IssueReviewHeader';
|
||||||
|
import { IssueReviewQueue } from './components/IssueReviewQueue';
|
||||||
|
import { IssueReviewSkeleton } from './components/IssueReviewSkeleton';
|
||||||
|
import {
|
||||||
|
getIssueId,
|
||||||
|
getIssueReviewStatus,
|
||||||
|
type ProofStatusFilter,
|
||||||
|
} from './components/reviewTypes';
|
||||||
|
|
||||||
|
const PROOF_STATUS_FILTERS: ProofStatusFilter[] = [
|
||||||
|
'all',
|
||||||
|
'pending',
|
||||||
|
'approved',
|
||||||
|
'rejected',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TicketClassReviewPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { ticketId, defectClass } = useParams() as {
|
||||||
|
ticketId: string;
|
||||||
|
defectClass: string;
|
||||||
|
};
|
||||||
|
const proofStatusParam = searchParams.get('proof_status');
|
||||||
|
const proofStatus: ProofStatusFilter = PROOF_STATUS_FILTERS.includes(
|
||||||
|
proofStatusParam as ProofStatusFilter,
|
||||||
|
)
|
||||||
|
? (proofStatusParam as ProofStatusFilter)
|
||||||
|
: 'all';
|
||||||
|
|
||||||
|
const overviewQuery = useTicketOverviewQuery(ticketId);
|
||||||
|
const ticket = overviewQuery.data;
|
||||||
|
const videoId = ticket?.video_id ?? ticket?.video?.id ?? undefined;
|
||||||
|
const detectionsQuery = useTicketClassReviewDetectionsQuery(
|
||||||
|
videoId,
|
||||||
|
defectClass,
|
||||||
|
proofStatus === 'all' ? 'submitted' : proofStatus,
|
||||||
|
);
|
||||||
|
const detectionResult = detectionsQuery.data?.pages[0];
|
||||||
|
const detections = useMemo(
|
||||||
|
() => detectionsQuery.data?.pages.flatMap((page) => page.items) ?? [],
|
||||||
|
[detectionsQuery.data?.pages],
|
||||||
|
);
|
||||||
|
const totalCount = detectionResult?.total ?? 0;
|
||||||
|
const selectedIssueParam = searchParams.get('issue_id') ?? undefined;
|
||||||
|
const reviewMutation = useReviewDetectionRepairProofMutation({
|
||||||
|
ticketId,
|
||||||
|
videoId,
|
||||||
|
defectClass,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectIssue = useCallback(
|
||||||
|
(issueId: string) => {
|
||||||
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
|
nextParams.set('issue_id', issueId);
|
||||||
|
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||||
|
},
|
||||||
|
[pathname, router, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleProofStatusChange = useCallback(
|
||||||
|
(status: ProofStatusFilter) => {
|
||||||
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
|
if (status === 'all') {
|
||||||
|
nextParams.delete('proof_status');
|
||||||
|
} else {
|
||||||
|
nextParams.set('proof_status', status);
|
||||||
|
}
|
||||||
|
nextParams.delete('issue_id');
|
||||||
|
const query = nextParams.toString();
|
||||||
|
router.replace(query ? `${pathname}?${query}` : pathname, {
|
||||||
|
scroll: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[pathname, router, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detections.length) return;
|
||||||
|
|
||||||
|
const hasSelectedIssue = detections.some(
|
||||||
|
(detection) => getIssueId(detection) === selectedIssueParam,
|
||||||
|
);
|
||||||
|
if (hasSelectedIssue) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedIssueParam &&
|
||||||
|
detectionsQuery.hasNextPage &&
|
||||||
|
!detectionsQuery.isFetchingNextPage
|
||||||
|
) {
|
||||||
|
void detectionsQuery.fetchNextPage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstReviewable = detections.find(
|
||||||
|
(detection) => getIssueReviewStatus(detection) === 'pending',
|
||||||
|
);
|
||||||
|
selectIssue(getIssueId(firstReviewable ?? detections[0]));
|
||||||
|
}, [detections, detectionsQuery, selectIssue, selectedIssueParam]);
|
||||||
|
|
||||||
|
const activeIndex = Math.max(
|
||||||
|
0,
|
||||||
|
detections.findIndex(
|
||||||
|
(detection) => getIssueId(detection) === selectedIssueParam,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const activeDetection = detections[activeIndex];
|
||||||
|
const activeIssueId = activeDetection
|
||||||
|
? getIssueId(activeDetection)
|
||||||
|
: undefined;
|
||||||
|
const activeReviewStatus = activeDetection
|
||||||
|
? getIssueReviewStatus(activeDetection)
|
||||||
|
: 'not_submitted';
|
||||||
|
|
||||||
|
const handleSaveReview = useCallback(
|
||||||
|
async (payload: ReviewDetectionRepairProofPayload) => {
|
||||||
|
if (!activeDetection) return;
|
||||||
|
|
||||||
|
await reviewMutation.mutateAsync({
|
||||||
|
detectionId: activeDetection.detection.id,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[activeDetection, reviewMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNext = useCallback(async () => {
|
||||||
|
if (activeIndex < detections.length - 1) {
|
||||||
|
selectIssue(getIssueId(detections[activeIndex + 1]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detectionsQuery.hasNextPage) return;
|
||||||
|
const nextResult = await detectionsQuery.fetchNextPage();
|
||||||
|
const nextDetections =
|
||||||
|
nextResult.data?.pages.flatMap((page) => page.items) ?? [];
|
||||||
|
const nextDetection = nextDetections[activeIndex + 1];
|
||||||
|
if (nextDetection) selectIssue(getIssueId(nextDetection));
|
||||||
|
}, [activeIndex, detections, detectionsQuery, selectIssue]);
|
||||||
|
|
||||||
|
const handleLoadMore = useCallback(() => {
|
||||||
|
if (detectionsQuery.hasNextPage && !detectionsQuery.isFetchingNextPage) {
|
||||||
|
void detectionsQuery.fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [detectionsQuery]);
|
||||||
|
|
||||||
|
const backToTicket = () => {
|
||||||
|
const params = new URLSearchParams({ defect_class: defectClass });
|
||||||
|
router.push(`${ROUTES.TICKET_DETAIL(ticketId)}?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (overviewQuery.isLoading || (videoId && detectionsQuery.isLoading)) {
|
||||||
|
return <IssueReviewSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overviewQuery.isError || !ticket || !videoId) {
|
||||||
|
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 review workspace.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Ticket or video information is unavailable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" onClick={backToTicket}>
|
||||||
|
Back to Ticket
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectionsQuery.isError) {
|
||||||
|
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">Failed to load class detections.</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
The review screen uses the detection API for its queue and
|
||||||
|
evidence.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={backToTicket}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void detectionsQuery.refetch()}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeDetection || !detectionResult) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<IssueReviewHeader
|
||||||
|
ticketLabel={ticket.ticket_name || ticket.id}
|
||||||
|
defectLabel={defectClass}
|
||||||
|
currentIndex={0}
|
||||||
|
total={0}
|
||||||
|
proofStatus={proofStatus}
|
||||||
|
onBack={backToTicket}
|
||||||
|
onPrevious={() => undefined}
|
||||||
|
onNext={() => undefined}
|
||||||
|
onProofStatusChange={handleProofStatusChange}
|
||||||
|
/>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
|
||||||
|
<ClipboardCheck className="size-8 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">No detections found.</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
There are no {defectClass} issues available for review.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defectLabel = activeDetection.detection.display_name;
|
||||||
|
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">
|
||||||
|
<IssueReviewHeader
|
||||||
|
ticketLabel={ticket.ticket_name || ticket.id}
|
||||||
|
defectLabel={defectLabel}
|
||||||
|
currentIndex={activeIndex}
|
||||||
|
total={totalCount}
|
||||||
|
proofStatus={proofStatus}
|
||||||
|
onBack={backToTicket}
|
||||||
|
onPrevious={() =>
|
||||||
|
selectIssue(getIssueId(detections[Math.max(0, activeIndex - 1)]))
|
||||||
|
}
|
||||||
|
onNext={() => void handleNext()}
|
||||||
|
onProofStatusChange={handleProofStatusChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[280px_minmax(0,1fr)_330px] xl:items-stretch xl:overflow-hidden">
|
||||||
|
<IssueReviewQueue
|
||||||
|
detections={detections}
|
||||||
|
totalCount={totalCount}
|
||||||
|
selectedIssueId={activeIssueId}
|
||||||
|
hasNextPage={Boolean(detectionsQuery.hasNextPage)}
|
||||||
|
isFetchingNextPage={detectionsQuery.isFetchingNextPage}
|
||||||
|
onSelect={selectIssue}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-2">
|
||||||
|
<IssueEvidencePanel
|
||||||
|
detection={activeDetection}
|
||||||
|
issueNumber={activeIndex + 1}
|
||||||
|
videoWidth={detectionResult.video_width}
|
||||||
|
videoHeight={detectionResult.video_height}
|
||||||
|
reviewStatus={activeReviewStatus}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-2">
|
||||||
|
<PermissionGuard
|
||||||
|
permissions={PERMISSIONS.TICKET.REVIEW}
|
||||||
|
fallback={
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center">
|
||||||
|
<ClipboardCheck className="mx-auto size-7 text-muted-foreground" />
|
||||||
|
<p className="mt-3 text-sm font-semibold">View-only access</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Review permission is required to take action.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IssueReviewAction
|
||||||
|
detection={activeDetection}
|
||||||
|
issueLabel={`${defectLabel} #${activeIndex + 1}`}
|
||||||
|
status={activeReviewStatus}
|
||||||
|
isPending={reviewMutation.isPending}
|
||||||
|
onSave={handleSaveReview}
|
||||||
|
/>
|
||||||
|
</PermissionGuard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ import { AssignedContractorAction } from './actions/AssignedContractorAction';
|
|||||||
import { AssignTicketAction } from './actions/AssignTicketAction';
|
import { AssignTicketAction } from './actions/AssignTicketAction';
|
||||||
import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
||||||
import { NoTicketAction } from './actions/NoTicketAction';
|
import { NoTicketAction } from './actions/NoTicketAction';
|
||||||
|
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
|
||||||
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
||||||
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
|
||||||
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
||||||
|
|
||||||
interface TicketStatusActionsProps {
|
interface TicketStatusActionsProps {
|
||||||
@@ -55,7 +55,7 @@ function getTicketAction(ticket: TicketDetail) {
|
|||||||
permissions={PERMISSIONS.TICKET.REVIEW}
|
permissions={PERMISSIONS.TICKET.REVIEW}
|
||||||
fallback={<NoTicketAction />}
|
fallback={<NoTicketAction />}
|
||||||
>
|
>
|
||||||
<ReviewRepairAction ticket={ticket} />
|
<OpenRepairReviewAction ticket={ticket} />
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ArrowRight, 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Check, ClipboardCheck, X } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import type { TicketDetail } from '@/types';
|
|
||||||
|
|
||||||
import { useReviewRepairMutation } from '../../../hooks/useTicketQueries';
|
|
||||||
import { TicketActionCard } from './TicketActionCard';
|
|
||||||
|
|
||||||
export function ReviewRepairAction({ ticket }: { ticket: TicketDetail }) {
|
|
||||||
const [reviewComment, setReviewComment] = useState('');
|
|
||||||
const reviewRepairMutation = useReviewRepairMutation(ticket.id);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setReviewComment(ticket.reviewer?.review_comment ?? '');
|
|
||||||
}, [ticket]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TicketActionCard icon={ClipboardCheck} title="Review Repair">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Comment</Label>
|
|
||||||
<Textarea
|
|
||||||
value={reviewComment}
|
|
||||||
onChange={(event) => setReviewComment(event.target.value)}
|
|
||||||
placeholder="Repair verified"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<Button
|
|
||||||
variant={'secondary'}
|
|
||||||
disabled={!reviewComment || reviewRepairMutation.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
reviewRepairMutation.mutate({
|
|
||||||
defect_class: ticket.defect_class,
|
|
||||||
action: 'approve',
|
|
||||||
comment: reviewComment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Check />
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={!reviewComment || reviewRepairMutation.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
reviewRepairMutation.mutate({
|
|
||||||
defect_class: ticket.defect_class,
|
|
||||||
action: 'reject',
|
|
||||||
comment: reviewComment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<X />
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TicketActionCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import {
|
||||||
|
useInfiniteQuery,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { detectionService, ticketService, videoService } from '@/services/api';
|
import { detectionService, ticketService, videoService } from '@/services/api';
|
||||||
@@ -10,12 +15,13 @@ import type {
|
|||||||
CloseTicketPayload,
|
CloseTicketPayload,
|
||||||
DiscardDetectionPayload,
|
DiscardDetectionPayload,
|
||||||
RequestTicketExtensionPayload,
|
RequestTicketExtensionPayload,
|
||||||
|
ReviewDetectionRepairProofPayload,
|
||||||
ReviewTicketExtensionPayload,
|
ReviewTicketExtensionPayload,
|
||||||
ReviewRepairPayload,
|
|
||||||
SubmitRepairPayload,
|
SubmitRepairPayload,
|
||||||
SubmitRepairUploadPayload,
|
SubmitRepairUploadPayload,
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
TicketListParams,
|
TicketListParams,
|
||||||
|
DetectionProofStatus,
|
||||||
VideoDetectionsParams,
|
VideoDetectionsParams,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
@@ -118,6 +124,36 @@ export function useTicketClassDetectionsQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useTicketClassReviewDetectionsQuery(
|
||||||
|
videoId: string | undefined,
|
||||||
|
defectClass: string | undefined,
|
||||||
|
proofStatus?: DetectionProofStatus,
|
||||||
|
) {
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: ticketKeys.classReviewDetections(
|
||||||
|
videoId ?? '',
|
||||||
|
defectClass ?? '',
|
||||||
|
proofStatus,
|
||||||
|
),
|
||||||
|
queryFn: ({ pageParam }) =>
|
||||||
|
videoService.getVideoDetections(videoId as string, {
|
||||||
|
class_name: defectClass,
|
||||||
|
proof_status: proofStatus,
|
||||||
|
sort: 'timestamp_asc',
|
||||||
|
skip: pageParam,
|
||||||
|
limit: pageSize,
|
||||||
|
}),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const nextSkip = lastPage.skip + lastPage.items.length;
|
||||||
|
return nextSkip < lastPage.total ? nextSkip : undefined;
|
||||||
|
},
|
||||||
|
enabled: Boolean(videoId && defectClass),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useDiscardDetectionMutation(
|
export function useDiscardDetectionMutation(
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
videoId: string | undefined,
|
videoId: string | undefined,
|
||||||
@@ -158,6 +194,54 @@ export function useDiscardDetectionMutation(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useReviewDetectionRepairProofMutation({
|
||||||
|
ticketId,
|
||||||
|
videoId,
|
||||||
|
defectClass,
|
||||||
|
}: {
|
||||||
|
ticketId: string;
|
||||||
|
videoId: string | undefined;
|
||||||
|
defectClass: string;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
detectionId,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
detectionId: number;
|
||||||
|
payload: ReviewDetectionRepairProofPayload;
|
||||||
|
}) => detectionService.reviewRepairProof(ticketId, detectionId, payload),
|
||||||
|
onSuccess: async (_repairProof, variables) => {
|
||||||
|
toast.success(
|
||||||
|
variables.payload.action === 'approve'
|
||||||
|
? 'Repair proof approved'
|
||||||
|
: 'Repair proof rejected',
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
videoId
|
||||||
|
? queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.classDetectionLists(videoId),
|
||||||
|
})
|
||||||
|
: Promise.resolve(),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.defectClasses(ticketId),
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.overview(ticketId),
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to review repair proof'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useTicketExtensionRequestQuery(
|
export function useTicketExtensionRequestQuery(
|
||||||
ticketId: string | undefined,
|
ticketId: string | undefined,
|
||||||
assignmentId: number | undefined,
|
assignmentId: number | undefined,
|
||||||
@@ -306,26 +390,6 @@ export function useReviewTicketExtensionMutation(
|
|||||||
onError: () => toast.error('Failed to review extension request'),
|
onError: () => toast.error('Failed to review extension request'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export function useReviewRepairMutation(ticketId: string) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (payload: ReviewRepairPayload) =>
|
|
||||||
ticketService.reviewRepair(ticketId, payload),
|
|
||||||
onSuccess: (ticket, payload) => {
|
|
||||||
toast.success(
|
|
||||||
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
|
|
||||||
);
|
|
||||||
queryClient.setQueryData<TicketDetail>(
|
|
||||||
ticketKeys.classDetail(ticketId, payload.defect_class),
|
|
||||||
(current) => mergeTicketDetailResponse(current, ticket),
|
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
|
||||||
},
|
|
||||||
onError: () => toast.error('Failed to review repair'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCloseTicketMutation(ticketId: string) {
|
export function useCloseTicketMutation(ticketId: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ export const ticketKeys = {
|
|||||||
[...ticketKeys.details(), 'video', videoId, 'detections'] as const,
|
[...ticketKeys.details(), 'video', videoId, 'detections'] as const,
|
||||||
classDetections: (videoId: string, params: VideoDetectionsParams) =>
|
classDetections: (videoId: string, params: VideoDetectionsParams) =>
|
||||||
[...ticketKeys.classDetectionLists(videoId), params] as const,
|
[...ticketKeys.classDetectionLists(videoId), params] as const,
|
||||||
|
classReviewDetections: (
|
||||||
|
videoId: string,
|
||||||
|
defectClass: string,
|
||||||
|
proofStatus?: string,
|
||||||
|
) =>
|
||||||
|
[
|
||||||
|
...ticketKeys.classDetectionLists(videoId),
|
||||||
|
'class-review',
|
||||||
|
defectClass,
|
||||||
|
proofStatus ?? 'all',
|
||||||
|
] as const,
|
||||||
defectClasses: (ticketId: string) =>
|
defectClasses: (ticketId: string) =>
|
||||||
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
|
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
|
||||||
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
|
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ export const API_ROUTES = {
|
|||||||
`/biz/api/v1/tickets/${ticketId}/extension-requests`,
|
`/biz/api/v1/tickets/${ticketId}/extension-requests`,
|
||||||
REVIEW_EXTENSION_REQUEST: (ticketId: string, assignmentId: number) =>
|
REVIEW_EXTENSION_REQUEST: (ticketId: string, assignmentId: number) =>
|
||||||
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-request/decision`,
|
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-request/decision`,
|
||||||
REVIEW: (id: string) => `/biz/api/v1/tickets/${id}/review`,
|
REVIEW_DETECTION_REPAIR_PROOF: (ticketId: string, detectionId: number) =>
|
||||||
|
`/biz/api/v1/tickets/${ticketId}/detections/${detectionId}/repair-proof/review`,
|
||||||
CLOSE: (id: string) => `/biz/api/v1/tickets/${id}/close`,
|
CLOSE: (id: string) => `/biz/api/v1/tickets/${id}/close`,
|
||||||
DETAIL_EVENTS_TOKEN: (id: string) =>
|
DETAIL_EVENTS_TOKEN: (id: string) =>
|
||||||
`/biz/api/v1/tickets/${id}/events/token`,
|
`/biz/api/v1/tickets/${id}/events/token`,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { API_ROUTES } from '@/constants/apiRoutes';
|
|||||||
import type {
|
import type {
|
||||||
DiscardDetectionPayload,
|
DiscardDetectionPayload,
|
||||||
DiscardDetectionResponse,
|
DiscardDetectionResponse,
|
||||||
|
ReviewDetectionRepairProofPayload,
|
||||||
|
ReviewDetectionRepairProofResponse,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
import axiosClient from '../axios/axios';
|
import axiosClient from '../axios/axios';
|
||||||
@@ -18,4 +20,17 @@ export const detectionService = {
|
|||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
reviewRepairProof: async (
|
||||||
|
ticketId: string,
|
||||||
|
detectionId: number,
|
||||||
|
payload: ReviewDetectionRepairProofPayload,
|
||||||
|
): Promise<ReviewDetectionRepairProofResponse> => {
|
||||||
|
const response = await axiosClient.post<ReviewDetectionRepairProofResponse>(
|
||||||
|
API_ROUTES.TICKETS.REVIEW_DETECTION_REPAIR_PROOF(ticketId, detectionId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type {
|
|||||||
CloseTicketPayload,
|
CloseTicketPayload,
|
||||||
RequestTicketExtensionPayload,
|
RequestTicketExtensionPayload,
|
||||||
ReviewTicketExtensionPayload,
|
ReviewTicketExtensionPayload,
|
||||||
ReviewRepairPayload,
|
|
||||||
SseTokenResponse,
|
SseTokenResponse,
|
||||||
SubmitRepairPayload,
|
SubmitRepairPayload,
|
||||||
SubmitRepairUploadPayload,
|
SubmitRepairUploadPayload,
|
||||||
@@ -162,17 +161,6 @@ export const ticketService = {
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
reviewRepair: async (
|
|
||||||
ticketId: string,
|
|
||||||
payload: ReviewRepairPayload,
|
|
||||||
): Promise<TicketDetail> => {
|
|
||||||
const response = await axiosClient.post<TicketDetail>(
|
|
||||||
API_ROUTES.TICKETS.REVIEW(ticketId),
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
closeTicket: async (
|
closeTicket: async (
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
payload: CloseTicketPayload,
|
payload: CloseTicketPayload,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const videoService = {
|
|||||||
skip: params?.skip ?? 0,
|
skip: params?.skip ?? 0,
|
||||||
limit: params?.limit ?? 1,
|
limit: params?.limit ?? 1,
|
||||||
class_name: params?.class_name,
|
class_name: params?.class_name,
|
||||||
|
proof_status: params?.proof_status,
|
||||||
min_confidence: params?.min_confidence,
|
min_confidence: params?.min_confidence,
|
||||||
sort: params?.sort ?? 'timestamp_asc',
|
sort: params?.sort ?? 'timestamp_asc',
|
||||||
search: params?.search,
|
search: params?.search,
|
||||||
|
|||||||
@@ -30,8 +30,17 @@ export type DetectionRepairProof = {
|
|||||||
submitted_at: string | null;
|
submitted_at: string | null;
|
||||||
reviewed_at: string | null;
|
reviewed_at: string | null;
|
||||||
review_comment: string | null;
|
review_comment: string | null;
|
||||||
|
reviewed_by_user_id?: number | null;
|
||||||
|
reviewed_by_name?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReviewDetectionRepairProofPayload = {
|
||||||
|
action: 'approve' | 'reject';
|
||||||
|
comment?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReviewDetectionRepairProofResponse = DetectionRepairProof;
|
||||||
|
|
||||||
export type DetectionResultItem = {
|
export type DetectionResultItem = {
|
||||||
id: string;
|
id: string;
|
||||||
detection: {
|
detection: {
|
||||||
@@ -70,11 +79,19 @@ export type VideoDetectionsParams = {
|
|||||||
skip?: number;
|
skip?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
class_name?: string | string[];
|
class_name?: string | string[];
|
||||||
|
proof_status?: DetectionProofStatus;
|
||||||
min_confidence?: number;
|
min_confidence?: number;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DetectionProofStatus =
|
||||||
|
| 'not_submitted'
|
||||||
|
| 'submitted'
|
||||||
|
| 'pending'
|
||||||
|
| 'approved'
|
||||||
|
| 'rejected';
|
||||||
|
|
||||||
export type VideoDetectionsResponse = {
|
export type VideoDetectionsResponse = {
|
||||||
video_id: string;
|
video_id: string;
|
||||||
video_width: number;
|
video_width: number;
|
||||||
|
|||||||
@@ -80,12 +80,6 @@ export interface TicketExtensionRequestsResponse {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewRepairPayload {
|
|
||||||
defect_class: string;
|
|
||||||
action: 'approve' | 'reject';
|
|
||||||
comment: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CloseTicketPayload {
|
export interface CloseTicketPayload {
|
||||||
defect_class: string;
|
defect_class: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,4 +15,7 @@ export const ROUTES = {
|
|||||||
ACCOUNT: '/account',
|
ACCOUNT: '/account',
|
||||||
UPLOAD: '/upload',
|
UPLOAD: '/upload',
|
||||||
TICKET: '/ticket',
|
TICKET: '/ticket',
|
||||||
|
TICKET_DETAIL: (ticketId: string) => `/ticket/${ticketId}`,
|
||||||
|
TICKET_CLASS_REVIEW: (ticketId: string, defectClass: string) =>
|
||||||
|
`/ticket/${ticketId}/${defectClass}/review`,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user