feat(ticket): add issue-by-issue repair review page

This commit is contained in:
2026-07-22 14:46:34 +05:30
parent 76c87811d8
commit e94da54aa9
19 changed files with 1262 additions and 109 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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')}`;
}

View File

@@ -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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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();

View File

@@ -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) =>

View File

@@ -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`,

View File

@@ -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;
},
}; };

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
} }

View File

@@ -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;