Compare commits
5 Commits
migration
...
e077467bce
| Author | SHA1 | Date | |
|---|---|---|---|
| e077467bce | |||
| e94da54aa9 | |||
| 76c87811d8 | |||
| 830883c6b7 | |||
| 68829dc72b |
@@ -0,0 +1,180 @@
|
|||||||
|
import { CalendarCheck2, ImageIcon, MapPin, Smartphone } 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">
|
||||||
|
<Smartphone />
|
||||||
|
Uploaded from App
|
||||||
|
</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">
|
||||||
|
Uploaded from the field app on{' '}
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Loader2, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import type { DetectionDiscardReason, DiscardDetectionPayload } from '@/types';
|
||||||
|
|
||||||
|
const DISCARD_REASON_OPTIONS: Array<{
|
||||||
|
value: DetectionDiscardReason;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: 'not_a_defect', label: 'Not a defect' },
|
||||||
|
{ value: 'wrong_class', label: 'Wrong classification' },
|
||||||
|
{ value: 'duplicate', label: 'Duplicate detection' },
|
||||||
|
{ value: 'poor_image_quality', label: 'Poor image quality' },
|
||||||
|
{ value: 'incorrect_location', label: 'Incorrect location' },
|
||||||
|
{ value: 'already_repaired', label: 'Already repaired' },
|
||||||
|
{ value: 'other', label: 'Other' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DetectionDiscardDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
detectionLabel: string;
|
||||||
|
isPending: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: DiscardDetectionPayload) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetectionDiscardDialog({
|
||||||
|
open,
|
||||||
|
detectionLabel,
|
||||||
|
isPending,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: DetectionDiscardDialogProps) {
|
||||||
|
const [reason, setReason] = useState<DetectionDiscardReason | ''>('');
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setReason('');
|
||||||
|
setComment('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (nextOpen: boolean) => {
|
||||||
|
if (isPending) return;
|
||||||
|
|
||||||
|
if (!nextOpen) resetForm();
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!reason || isPending) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit({
|
||||||
|
reason,
|
||||||
|
comment: comment.trim() || undefined,
|
||||||
|
});
|
||||||
|
resetForm();
|
||||||
|
} catch {
|
||||||
|
// The mutation displays the request error and keeps the dialog open.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent showCloseButton={!isPending}>
|
||||||
|
<form className="flex min-h-0 flex-1 flex-col" onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Discard detection</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Remove {detectionLabel} from this ticket and record why the AI
|
||||||
|
result is incorrect.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="discard-reason">Reason</Label>
|
||||||
|
<Select
|
||||||
|
value={reason}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setReason(value as DetectionDiscardReason)
|
||||||
|
}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="discard-reason" className="w-full">
|
||||||
|
<SelectValue placeholder="Select a reason" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DISCARD_REASON_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Label htmlFor="discard-comment">Comment</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Optional | {comment.length}/500
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
id="discard-comment"
|
||||||
|
value={comment}
|
||||||
|
onChange={(event) => setComment(event.target.value)}
|
||||||
|
placeholder="Add details that can help improve future detections"
|
||||||
|
rows={4}
|
||||||
|
maxLength={500}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={!reason || isPending}
|
||||||
|
>
|
||||||
|
{isPending ? <Loader2 className="animate-spin" /> : <Trash2 />}
|
||||||
|
{isPending ? 'Discarding' : 'Discard detection'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { AlertTriangle, ChevronLeft, ChevronRight } from 'lucide-react';
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ImageIcon,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -10,10 +16,18 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
|
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
|
||||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { DetectionRepairProof } from '@/types';
|
||||||
|
|
||||||
import { useTicketClassDetectionsQuery } from '../../hooks/useTicketQueries';
|
import {
|
||||||
|
useDiscardDetectionMutation,
|
||||||
|
useTicketClassDetectionsQuery,
|
||||||
|
} from '../../hooks/useTicketQueries';
|
||||||
|
import { DetectionDiscardDialog } from './DetectionDiscardDialog';
|
||||||
|
import { RepairProofGallery } from './repair/RepairProofGallery';
|
||||||
|
import { RepairSubmittedBanner } from './repair/RepairSubmittedBanner';
|
||||||
|
|
||||||
interface TicketClassDetectionPreviewProps {
|
interface TicketClassDetectionPreviewProps {
|
||||||
|
ticketId: string;
|
||||||
videoId?: string | null;
|
videoId?: string | null;
|
||||||
defectClassName: string;
|
defectClassName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -49,6 +63,44 @@ function DetectionMetadataBar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DetectionRepairProofPanel({
|
||||||
|
repairProof,
|
||||||
|
}: {
|
||||||
|
repairProof: DetectionRepairProof;
|
||||||
|
}) {
|
||||||
|
if (!repairProof.submitted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4 rounded-lg border bg-muted/20 p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="size-5 text-primary" />
|
||||||
|
<h4 className="font-semibold">Repair Proof</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{repairProof.submitted_at ? (
|
||||||
|
<RepairSubmittedBanner
|
||||||
|
submittedAt={repairProof.submitted_at}
|
||||||
|
submitterName="Ticket Worker"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<RepairProofGallery
|
||||||
|
imageUrls={repairProof.proof_image_urls}
|
||||||
|
maxVisibleImages={4}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<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 provided.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TicketClassDetectionPreviewSkeleton() {
|
function TicketClassDetectionPreviewSkeleton() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -89,12 +141,14 @@ function TicketClassDetectionPreviewSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TicketClassDetectionPreview({
|
export function TicketClassDetectionPreview({
|
||||||
|
ticketId,
|
||||||
videoId,
|
videoId,
|
||||||
defectClassName,
|
defectClassName,
|
||||||
displayName,
|
displayName,
|
||||||
totalCount,
|
totalCount,
|
||||||
}: TicketClassDetectionPreviewProps) {
|
}: TicketClassDetectionPreviewProps) {
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
|
||||||
const visual = getDefectVisual(defectClassName);
|
const visual = getDefectVisual(defectClassName);
|
||||||
const IssueIcon = visual.icon;
|
const IssueIcon = visual.icon;
|
||||||
|
|
||||||
@@ -116,15 +170,14 @@ export function TicketClassDetectionPreview({
|
|||||||
videoId ?? undefined,
|
videoId ?? undefined,
|
||||||
queryParams,
|
queryParams,
|
||||||
);
|
);
|
||||||
|
const discardMutation = useDiscardDetectionMutation(
|
||||||
|
ticketId,
|
||||||
|
videoId ?? undefined,
|
||||||
|
defectClassName,
|
||||||
|
);
|
||||||
const detectionResult = detectionsQuery.data;
|
const detectionResult = detectionsQuery.data;
|
||||||
const activeDetection = detectionResult?.items[0];
|
const activeDetection = detectionResult?.items[0];
|
||||||
const detectionCount = detectionResult?.total ?? totalCount ?? 0;
|
const detectionCount = detectionResult?.total ?? totalCount ?? 0;
|
||||||
const mediaAspectRatio =
|
|
||||||
detectionResult &&
|
|
||||||
detectionResult.video_width > 0 &&
|
|
||||||
detectionResult.video_height > 0
|
|
||||||
? `${detectionResult.video_width} / ${detectionResult.video_height}`
|
|
||||||
: '16 / 9';
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (detectionCount === 0 && currentIndex !== 0) {
|
if (detectionCount === 0 && currentIndex !== 0) {
|
||||||
@@ -237,11 +290,13 @@ export function TicketClassDetectionPreview({
|
|||||||
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2">
|
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2">
|
||||||
<div className="min-w-0 space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
<p className="text-muted-foreground">Detection Image</p>
|
<p className="text-muted-foreground">Detection Image</p>
|
||||||
<div className="overflow-hidden rounded-lg">
|
<div className="w-full overflow-hidden rounded-lg">
|
||||||
<AnnotatedDetectionImage
|
<AnnotatedDetectionImage
|
||||||
detection={activeDetection}
|
detection={activeDetection}
|
||||||
videoWidth={detectionResult.video_width}
|
videoWidth={detectionResult.video_width}
|
||||||
videoHeight={detectionResult.video_height}
|
videoHeight={detectionResult.video_height}
|
||||||
|
aspectRatio="16 / 9"
|
||||||
|
enableLightbox
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +308,7 @@ export function TicketClassDetectionPreview({
|
|||||||
label={`${displayName} - Frame ${activeDetection.frame.number}`}
|
label={`${displayName} - Frame ${activeDetection.frame.number}`}
|
||||||
title="Location on Map"
|
title="Location on Map"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
aspectRatio={mediaAspectRatio}
|
aspectRatio="16 / 9"
|
||||||
showCoordinates={false}
|
showCoordinates={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,6 +329,37 @@ export function TicketClassDetectionPreview({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{activeDetection.repair_proof ? (
|
||||||
|
<DetectionRepairProofPanel
|
||||||
|
repairProof={activeDetection.repair_proof}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setIsDiscardDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
Discard detection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DetectionDiscardDialog
|
||||||
|
open={isDiscardDialogOpen}
|
||||||
|
detectionLabel={activeDetection.detection.display_name}
|
||||||
|
isPending={discardMutation.isPending}
|
||||||
|
onOpenChange={setIsDiscardDialogOpen}
|
||||||
|
onSubmit={async (payload) => {
|
||||||
|
await discardMutation.mutateAsync({
|
||||||
|
detectionId: activeDetection.detection.id,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
setIsDiscardDialogOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export function TicketDefectClassTabs({
|
|||||||
|
|
||||||
<div className="min-w-0 px-5">
|
<div className="min-w-0 px-5">
|
||||||
<TicketClassDetectionPreview
|
<TicketClassDetectionPreview
|
||||||
|
ticketId={ticketId}
|
||||||
videoId={previewVideoId}
|
videoId={previewVideoId}
|
||||||
defectClassName={selectedClass}
|
defectClassName={selectedClass}
|
||||||
displayName={previewDisplayName}
|
displayName={previewDisplayName}
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ 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 { RequestExtensionAction } from './actions/RequestExtensionAction';
|
||||||
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
||||||
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
|
||||||
import { SubmittedRepairProofAction } from './actions/SubmittedRepairProofAction';
|
|
||||||
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
|
||||||
|
|
||||||
interface TicketStatusActionsProps {
|
interface TicketStatusActionsProps {
|
||||||
ticket: TicketDetail;
|
ticket: TicketDetail;
|
||||||
@@ -38,7 +37,7 @@ function getTicketAction(ticket: TicketDetail) {
|
|||||||
permissions={PERMISSIONS.TICKET.WORK}
|
permissions={PERMISSIONS.TICKET.WORK}
|
||||||
fallback={<NoTicketAction />}
|
fallback={<NoTicketAction />}
|
||||||
>
|
>
|
||||||
<SubmitRepairAction ticket={ticket} />
|
<RequestExtensionAction ticket={ticket} />
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -56,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -72,15 +71,5 @@ function getTicketAction(ticket: TicketDetail) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
|
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
|
||||||
const action = getTicketAction(ticket);
|
return <div className="space-y-3">{getTicketAction(ticket)}</div>;
|
||||||
const submittedProof = ticket.repair?.submitted_at ? (
|
|
||||||
<SubmittedRepairProofAction ticket={ticket} />
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{submittedProof}
|
|
||||||
{action}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,485 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
||||||
import {
|
|
||||||
Camera,
|
|
||||||
ImageIcon,
|
|
||||||
Loader2,
|
|
||||||
Play,
|
|
||||||
Plus,
|
|
||||||
Send,
|
|
||||||
Video,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
import { useSubmitRepairUploadMutation } from '../../../hooks/useTicketQueries';
|
|
||||||
|
|
||||||
const MAX_IMAGES = 30;
|
|
||||||
const MAX_NOTES = 500;
|
|
||||||
|
|
||||||
function RequiredMark() {
|
|
||||||
return <span className="text-destructive">*</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatVideoDuration(seconds: number) {
|
|
||||||
const totalSeconds = Math.floor(seconds);
|
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
|
||||||
const remainingSeconds = totalSeconds % 60;
|
|
||||||
return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaSectionHeaderProps {
|
|
||||||
icon: ReactNode;
|
|
||||||
title: string;
|
|
||||||
addLabel: string;
|
|
||||||
onAdd: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MediaSectionHeader({
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
addLabel,
|
|
||||||
onAdd,
|
|
||||||
disabled = false,
|
|
||||||
}: MediaSectionHeaderProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-primary">{icon}</span>
|
|
||||||
<span className="text-sm font-semibold">{title}</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onAdd}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
<Plus className="size-3.5" />
|
|
||||||
{addLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MediaHint({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<p className="rounded-lg bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EmptyMediaPlaceholderProps {
|
|
||||||
icon: ReactNode;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyMediaPlaceholder({
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
className,
|
|
||||||
disabled = false,
|
|
||||||
onClick,
|
|
||||||
}: EmptyMediaPlaceholderProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
'flex w-full min-w-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/80 bg-muted/15 px-4 py-6 text-center transition-colors',
|
|
||||||
'hover:border-primary/40 hover:bg-primary/5',
|
|
||||||
disabled && 'cursor-not-allowed opacity-60',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
|
||||||
<span className="max-w-xs text-xs text-muted-foreground">
|
|
||||||
{description}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImageThumbnailProps {
|
|
||||||
file: File;
|
|
||||||
previewUrl: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
onRemove: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImageThumbnail({
|
|
||||||
file,
|
|
||||||
previewUrl,
|
|
||||||
disabled = false,
|
|
||||||
onRemove,
|
|
||||||
}: ImageThumbnailProps) {
|
|
||||||
return (
|
|
||||||
<div className="group relative size-20 shrink-0 overflow-hidden rounded-lg border bg-muted/30">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={previewUrl}
|
|
||||||
alt={file.name}
|
|
||||||
className="size-full object-cover"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onRemove}
|
|
||||||
className="absolute top-1 right-1 flex size-5 cursor-pointer items-center justify-center rounded-full bg-foreground/80 text-background shadow-sm transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
aria-label={`Remove ${file.name}`}
|
|
||||||
>
|
|
||||||
<X className="size-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VideoThumbnailProps {
|
|
||||||
file: File;
|
|
||||||
previewUrl: string;
|
|
||||||
duration: string | null;
|
|
||||||
disabled?: boolean;
|
|
||||||
onRemove: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function VideoThumbnail({
|
|
||||||
file,
|
|
||||||
previewUrl,
|
|
||||||
duration,
|
|
||||||
disabled = false,
|
|
||||||
onRemove,
|
|
||||||
}: VideoThumbnailProps) {
|
|
||||||
return (
|
|
||||||
<div className="group relative h-20 w-36 shrink-0 overflow-hidden rounded-lg border bg-muted/30">
|
|
||||||
<video
|
|
||||||
src={previewUrl}
|
|
||||||
className="size-full object-cover"
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
preload="metadata"
|
|
||||||
aria-label={file.name}
|
|
||||||
/>
|
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20">
|
|
||||||
<span className="flex size-8 items-center justify-center rounded-full bg-background/90 text-foreground shadow-sm">
|
|
||||||
<Play className="size-3.5 fill-current" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{duration ? (
|
|
||||||
<span className="pointer-events-none absolute bottom-1.5 right-1.5 rounded-lg bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
|
||||||
{duration}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onRemove}
|
|
||||||
className="absolute top-1 right-1 flex size-5 cursor-pointer items-center justify-center rounded-full bg-foreground/80 text-background shadow-sm transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
aria-label={`Remove ${file.name}`}
|
|
||||||
>
|
|
||||||
<X className="size-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RepairEvidenceFormProps {
|
|
||||||
ticketId: string;
|
|
||||||
defectClass: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RepairEvidenceForm({
|
|
||||||
ticketId,
|
|
||||||
defectClass,
|
|
||||||
}: RepairEvidenceFormProps) {
|
|
||||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
const videoInputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
const [notes, setNotes] = useState('');
|
|
||||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
|
||||||
const [videoPreview, setVideoPreview] = useState<string | null>(null);
|
|
||||||
const [videoDuration, setVideoDuration] = useState<string | null>(null);
|
|
||||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
|
||||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const submitRepairUploadMutation = useSubmitRepairUploadMutation(ticketId);
|
|
||||||
const isSubmitting = submitRepairUploadMutation.isPending;
|
|
||||||
const canSubmit = Boolean(notes.trim() && videoFile);
|
|
||||||
const canAddMoreImages = imageFiles.length < MAX_IMAGES;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const urls = imageFiles.map((file) => URL.createObjectURL(file));
|
|
||||||
setImagePreviews(urls);
|
|
||||||
return () => {
|
|
||||||
urls.forEach((url) => URL.revokeObjectURL(url));
|
|
||||||
};
|
|
||||||
}, [imageFiles]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!videoFile) {
|
|
||||||
setVideoPreview(null);
|
|
||||||
setVideoDuration(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(videoFile);
|
|
||||||
setVideoPreview(url);
|
|
||||||
setVideoDuration(null);
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const video = document.createElement('video');
|
|
||||||
video.preload = 'metadata';
|
|
||||||
video.onloadedmetadata = () => {
|
|
||||||
if (cancelled || !Number.isFinite(video.duration)) return;
|
|
||||||
setVideoDuration(formatVideoDuration(video.duration));
|
|
||||||
};
|
|
||||||
video.src = url;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
}, [videoFile]);
|
|
||||||
|
|
||||||
const addImages = (files: FileList | null) => {
|
|
||||||
if (!files?.length) return;
|
|
||||||
|
|
||||||
const remaining = MAX_IMAGES - imageFiles.length;
|
|
||||||
if (remaining <= 0) {
|
|
||||||
setError(`Only ${MAX_IMAGES} images can be uploaded.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextImages = [
|
|
||||||
...imageFiles,
|
|
||||||
...Array.from(files).slice(0, remaining),
|
|
||||||
];
|
|
||||||
setImageFiles(nextImages);
|
|
||||||
setError(
|
|
||||||
files.length > remaining
|
|
||||||
? `Only ${MAX_IMAGES} images can be uploaded.`
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectVideo = (files: FileList | null) => {
|
|
||||||
setVideoFile(files?.[0] ?? null);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeVideo = () => {
|
|
||||||
setVideoFile(null);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeImage = (index: number) => {
|
|
||||||
setImageFiles((current) =>
|
|
||||||
current.filter((_, itemIndex) => itemIndex !== index),
|
|
||||||
);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!notes.trim() || !videoFile) {
|
|
||||||
setError('Repair notes and a video are required.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await submitRepairUploadMutation.mutateAsync({
|
|
||||||
defect_class: defectClass,
|
|
||||||
notes: notes.trim(),
|
|
||||||
video: videoFile,
|
|
||||||
images: imageFiles,
|
|
||||||
});
|
|
||||||
setNotes('');
|
|
||||||
setVideoFile(null);
|
|
||||||
setImageFiles([]);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Upload failed');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openImagePicker = () => {
|
|
||||||
if (!isSubmitting && canAddMoreImages) {
|
|
||||||
imageInputRef.current?.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openVideoPicker = () => {
|
|
||||||
if (!isSubmitting) {
|
|
||||||
videoInputRef.current?.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex gap-3 rounded-lg border border-primary/15 bg-primary/5 p-4">
|
|
||||||
<Camera className="mt-0.5 size-5 shrink-0 text-primary" />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-semibold">Instructions</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Upload clear photos and one video of the repaired area and provide
|
|
||||||
relevant details.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={imageInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/*"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
disabled={isSubmitting || !canAddMoreImages}
|
|
||||||
onChange={(event) => {
|
|
||||||
addImages(event.target.files);
|
|
||||||
event.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref={videoInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="video/mp4,video/quicktime,video/x-msvideo,video/*"
|
|
||||||
className="hidden"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onChange={(event) => {
|
|
||||||
selectVideo(event.target.files);
|
|
||||||
event.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<MediaSectionHeader
|
|
||||||
icon={<Camera className="size-4" />}
|
|
||||||
title="Photos"
|
|
||||||
addLabel="Add Photos"
|
|
||||||
onAdd={openImagePicker}
|
|
||||||
disabled={isSubmitting || !canAddMoreImages}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
||||||
{imageFiles.length === 0 ? (
|
|
||||||
<EmptyMediaPlaceholder
|
|
||||||
icon={<ImageIcon className="size-5" />}
|
|
||||||
title="No photos added yet"
|
|
||||||
description="Tap to upload repair photos"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={openImagePicker}
|
|
||||||
className="min-h-26"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex min-w-min gap-3">
|
|
||||||
{imageFiles.map((file, index) => (
|
|
||||||
<ImageThumbnail
|
|
||||||
key={`${file.name}-${file.lastModified}-${index}`}
|
|
||||||
file={file}
|
|
||||||
previewUrl={imagePreviews[index]}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onRemove={() => removeImage(index)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MediaHint>
|
|
||||||
You can upload up to {MAX_IMAGES} images (JPG, PNG)
|
|
||||||
</MediaHint>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Video className="size-4 text-primary" />
|
|
||||||
<span className="text-sm font-semibold">Video</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{!videoFile || !videoPreview ? (
|
|
||||||
<EmptyMediaPlaceholder
|
|
||||||
icon={<Video className="size-5" />}
|
|
||||||
title="No video added yet"
|
|
||||||
description="Tap to upload a repair video"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={openVideoPicker}
|
|
||||||
className="min-h-[104px]"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<VideoThumbnail
|
|
||||||
file={videoFile}
|
|
||||||
previewUrl={videoPreview}
|
|
||||||
duration={videoDuration}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onRemove={removeVideo}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MediaHint>You can upload one video (MP4, MOV, AVI)</MediaHint>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="repair-notes">
|
|
||||||
Repair Notes <RequiredMark />
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Textarea
|
|
||||||
id="repair-notes"
|
|
||||||
value={notes}
|
|
||||||
onChange={(event) => {
|
|
||||||
setNotes(event.target.value.slice(0, MAX_NOTES));
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
placeholder="Describe the repair work completed, materials used, and any additional observations."
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="min-h-28 resize-none pb-8"
|
|
||||||
/>
|
|
||||||
<span className="pointer-events-none absolute bottom-2 right-3 text-xs text-muted-foreground">
|
|
||||||
{notes.length} / {MAX_NOTES}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full"
|
|
||||||
disabled={!canSubmit || isSubmitting}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
Submitting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Send className="size-4" />
|
|
||||||
Submit for Review
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Clock3 } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
import { TicketActionCard } from './TicketActionCard';
|
||||||
|
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
|
||||||
|
|
||||||
|
export function RequestExtensionAction({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
return (
|
||||||
|
<TicketActionCard icon={Clock3} title="Request Extension">
|
||||||
|
<TicketExtensionRequestPanel ticket={ticket} />
|
||||||
|
</TicketActionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,13 +16,9 @@ const MAX_REASON_LENGTH = 300;
|
|||||||
|
|
||||||
interface RequestExtensionFormProps {
|
interface RequestExtensionFormProps {
|
||||||
ticket: TicketDetail;
|
ticket: TicketDetail;
|
||||||
onCancel: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RequestExtensionForm({
|
export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) {
|
||||||
ticket,
|
|
||||||
onCancel,
|
|
||||||
}: RequestExtensionFormProps) {
|
|
||||||
const currentDueAt = ticket.worker?.due_at;
|
const currentDueAt = ticket.worker?.due_at;
|
||||||
const currentDueDate = useMemo(
|
const currentDueDate = useMemo(
|
||||||
() => (currentDueAt ? new Date(currentDueAt) : undefined),
|
() => (currentDueAt ? new Date(currentDueAt) : undefined),
|
||||||
@@ -66,7 +62,6 @@ export function RequestExtensionForm({
|
|||||||
});
|
});
|
||||||
setRequestedDueDate(undefined);
|
setRequestedDueDate(undefined);
|
||||||
setReason('');
|
setReason('');
|
||||||
onCancel();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -138,20 +133,15 @@ export function RequestExtensionForm({
|
|||||||
This request will be sent to your manager for approval.
|
This request will be sent to your manager for approval.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
className="w-full"
|
||||||
disabled={isPending}
|
disabled={!canSubmit}
|
||||||
onClick={onCancel}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="button" disabled={!canSubmit} onClick={handleSubmit}>
|
|
||||||
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
|
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
|
||||||
{isPending ? 'Submitting...' : 'Submit Request'}
|
{isPending ? 'Submitting...' : 'Submit Request'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,52 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Wrench } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import type { TicketDetail } from '@/types';
|
|
||||||
|
|
||||||
import { RepairEvidenceForm } from './RepairEvidenceForm';
|
|
||||||
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
|
|
||||||
import { TicketActionCard } from './TicketActionCard';
|
|
||||||
|
|
||||||
type RepairActionTab = 'repair-proof' | 'request-extension';
|
|
||||||
|
|
||||||
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
|
|
||||||
const [activeTab, setActiveTab] = useState<RepairActionTab>('repair-proof');
|
|
||||||
|
|
||||||
if (ticket.repair?.submitted_at) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TicketActionCard icon={Wrench} title="Repair Proof">
|
|
||||||
<Tabs
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={(value) => setActiveTab(value as RepairActionTab)}
|
|
||||||
className="gap-5"
|
|
||||||
>
|
|
||||||
<TabsList variant="line" className="grid h-auto w-full grid-cols-2 p-0">
|
|
||||||
<TabsTrigger value="repair-proof" className="pb-3">
|
|
||||||
Upload Repair Proof
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="request-extension" className="pb-3">
|
|
||||||
Request Extension
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="repair-proof">
|
|
||||||
<RepairEvidenceForm
|
|
||||||
ticketId={ticket.id}
|
|
||||||
defectClass={ticket.defect_class}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="request-extension">
|
|
||||||
<TicketExtensionRequestPanel
|
|
||||||
ticket={ticket}
|
|
||||||
onCancelRequest={() => setActiveTab('repair-proof')}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</TicketActionCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { RepairProofSummary as SubmittedRepairProofAction } from '../repair/RepairProofSummary';
|
|
||||||
@@ -23,11 +23,6 @@ import { formatDate } from '@/utils/date';
|
|||||||
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
|
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
|
||||||
import { RequestExtensionForm } from './RequestExtensionForm';
|
import { RequestExtensionForm } from './RequestExtensionForm';
|
||||||
|
|
||||||
interface TicketExtensionRequestPanelProps {
|
|
||||||
ticket: TicketDetail;
|
|
||||||
onCancelRequest: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DetailRowProps {
|
interface DetailRowProps {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -192,8 +187,9 @@ function ExtensionRequestDetails({
|
|||||||
|
|
||||||
export function TicketExtensionRequestPanel({
|
export function TicketExtensionRequestPanel({
|
||||||
ticket,
|
ticket,
|
||||||
onCancelRequest,
|
}: {
|
||||||
}: TicketExtensionRequestPanelProps) {
|
ticket: TicketDetail;
|
||||||
|
}) {
|
||||||
const assignmentId = ticket.worker?.assignment_id;
|
const assignmentId = ticket.worker?.assignment_id;
|
||||||
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
||||||
ticket.id,
|
ticket.id,
|
||||||
@@ -241,7 +237,7 @@ export function TicketExtensionRequestPanel({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!latestRequest) {
|
if (!latestRequest) {
|
||||||
return <RequestExtensionForm ticket={ticket} onCancel={onCancelRequest} />;
|
return <RequestExtensionForm ticket={ticket} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ExtensionRequestDetails request={latestRequest} />;
|
return <ExtensionRequestDetails request={latestRequest} />;
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ import { protectedMediaService } from '@/services/api';
|
|||||||
|
|
||||||
interface RepairProofGalleryProps {
|
interface RepairProofGalleryProps {
|
||||||
imageUrls: string[];
|
imageUrls: string[];
|
||||||
|
maxVisibleImages?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_VISIBLE_IMAGES = 8;
|
const MAX_VISIBLE_IMAGES = 8;
|
||||||
|
|
||||||
export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
|
export function RepairProofGallery({
|
||||||
|
imageUrls,
|
||||||
|
maxVisibleImages = MAX_VISIBLE_IMAGES,
|
||||||
|
}: RepairProofGalleryProps) {
|
||||||
const queries = useQueries({
|
const queries = useQueries({
|
||||||
queries: imageUrls.map((url) => ({
|
queries: imageUrls.map((url) => ({
|
||||||
queryKey: ['protected-media', 'v2', url],
|
queryKey: ['protected-media', 'v2', url],
|
||||||
@@ -44,7 +48,7 @@ export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
|
|||||||
if (queries.some((query) => query.isLoading)) {
|
if (queries.some((query) => query.isLoading)) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-4 gap-1 overflow-hidden rounded-lg">
|
<div className="grid grid-cols-4 gap-1 overflow-hidden rounded-lg">
|
||||||
{imageUrls.slice(0, MAX_VISIBLE_IMAGES).map((url, index) => (
|
{imageUrls.slice(0, maxVisibleImages).map((url, index) => (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
key={`${url}-${index}`}
|
key={`${url}-${index}`}
|
||||||
className="aspect-square rounded-lg"
|
className="aspect-square rounded-lg"
|
||||||
@@ -57,7 +61,7 @@ export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
|
|||||||
const images = objectUrls.flatMap((url, index) =>
|
const images = objectUrls.flatMap((url, index) =>
|
||||||
url ? [{ url, originalIndex: index }] : [],
|
url ? [{ url, originalIndex: index }] : [],
|
||||||
);
|
);
|
||||||
const visibleCount = Math.min(MAX_VISIBLE_IMAGES, images.length);
|
const visibleCount = Math.min(maxVisibleImages, images.length);
|
||||||
const overflowCount = images.length - visibleCount;
|
const overflowCount = images.length - visibleCount;
|
||||||
|
|
||||||
if (images.length === 0) {
|
if (images.length === 0) {
|
||||||
@@ -102,8 +106,8 @@ export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
|
|||||||
className="size-full object-cover transition duration-300 group-hover:scale-[1.03] group-hover:brightness-90"
|
className="size-full object-cover transition duration-300 group-hover:scale-[1.03] group-hover:brightness-90"
|
||||||
/>
|
/>
|
||||||
{isOverflowTile ? (
|
{isOverflowTile ? (
|
||||||
<span className="absolute inset-0 flex items-center justify-center bg-black/55 text-xl font-semibold text-white transition-colors group-hover:bg-black/65">
|
<span className="absolute inset-0 flex items-center justify-center bg-black/55 px-2 text-center text-sm font-semibold text-white transition-colors group-hover:bg-black/65 sm:text-base">
|
||||||
+{overflowCount}
|
+{overflowCount} more
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import { CheckCircle2, HardHat, Wrench, ToolCase } from 'lucide-react';
|
|
||||||
|
|
||||||
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import type { TicketDetail } from '@/types';
|
|
||||||
|
|
||||||
import { formatDate } from '@/utils/date';
|
|
||||||
import { RepairProofGallery } from './RepairProofGallery';
|
|
||||||
|
|
||||||
function SubmittedBanner({
|
|
||||||
submittedAt,
|
|
||||||
submitterName,
|
|
||||||
}: {
|
|
||||||
submittedAt: string;
|
|
||||||
submitterName: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-3 dark:border-emerald-900/60 dark:bg-emerald-950/30">
|
|
||||||
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
|
||||||
<div className="min-w-0 space-y-0.5">
|
|
||||||
<p className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
|
|
||||||
Repair Submitted
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-emerald-700/80 dark:text-emerald-300/80">
|
|
||||||
{formatDate(submittedAt)} by {submitterName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImageGallery({
|
|
||||||
imageUrls,
|
|
||||||
hasVideo,
|
|
||||||
}: {
|
|
||||||
imageUrls: string[];
|
|
||||||
hasVideo: boolean;
|
|
||||||
}) {
|
|
||||||
if (imageUrls.length === 0) {
|
|
||||||
return hasVideo ? null : (
|
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-dashed px-3 py-4 text-sm text-muted-foreground">
|
|
||||||
<HardHat className="size-4 shrink-0" />
|
|
||||||
No media attached
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <RepairProofGallery imageUrls={imageUrls} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Notes({ notes }: { notes: string | null | undefined }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<p className="text-xs font-semibold text-foreground">Repair Notes</p>
|
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
||||||
{notes?.trim() || 'No repair notes provided.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RepairProofSummary({ ticket }: { ticket: TicketDetail }) {
|
|
||||||
const repair = ticket.repair;
|
|
||||||
|
|
||||||
if (!repair?.submitted_at) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitterName = ticket.worker?.name?.trim() || 'Assigned worker';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<ToolCase className="text-primary" />
|
|
||||||
Repair Proof
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<SubmittedBanner
|
|
||||||
submittedAt={repair.submitted_at}
|
|
||||||
submitterName={submitterName}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ProtectedVideoPlayer
|
|
||||||
url={repair.proof_video_url}
|
|
||||||
meta="Contractor evidence video"
|
|
||||||
emptyTitle="No repair video uploaded"
|
|
||||||
emptyDescription="The assigned worker has not uploaded a repair video."
|
|
||||||
className="rounded-lg"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ImageGallery
|
|
||||||
imageUrls={repair.proof_image_urls}
|
|
||||||
hasVideo={Boolean(repair.proof_video_url)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Notes notes={repair.notes} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
|
interface RepairSubmittedBannerProps {
|
||||||
|
submittedAt: string;
|
||||||
|
submitterName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepairSubmittedBanner({
|
||||||
|
submittedAt,
|
||||||
|
submitterName,
|
||||||
|
}: RepairSubmittedBannerProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-3 dark:border-emerald-900/60 dark:bg-emerald-950/30">
|
||||||
|
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
<div className="min-w-0 space-y-0.5">
|
||||||
|
<p className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
|
||||||
|
Repair Submitted
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-emerald-700/80 dark:text-emerald-300/80">
|
||||||
|
{formatDate(submittedAt)} by {submitterName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,25 @@
|
|||||||
'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 { ticketService, videoService } from '@/services/api';
|
import { detectionService, ticketService, videoService } from '@/services/api';
|
||||||
import type {
|
import type {
|
||||||
AssignTicketPayload,
|
AssignTicketPayload,
|
||||||
CloseTicketPayload,
|
CloseTicketPayload,
|
||||||
|
DiscardDetectionPayload,
|
||||||
RequestTicketExtensionPayload,
|
RequestTicketExtensionPayload,
|
||||||
|
ReviewDetectionRepairProofPayload,
|
||||||
ReviewTicketExtensionPayload,
|
ReviewTicketExtensionPayload,
|
||||||
ReviewRepairPayload,
|
|
||||||
SubmitRepairPayload,
|
|
||||||
SubmitRepairUploadPayload,
|
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
TicketListParams,
|
TicketListParams,
|
||||||
|
DetectionProofStatus,
|
||||||
VideoDetectionsParams,
|
VideoDetectionsParams,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
@@ -117,6 +122,124 @@ 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(
|
||||||
|
ticketId: string,
|
||||||
|
videoId: string | undefined,
|
||||||
|
defectClass: string,
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
detectionId,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
detectionId: number;
|
||||||
|
payload: DiscardDetectionPayload;
|
||||||
|
}) => detectionService.discardDetection(detectionId, payload),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('Detection discarded');
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
videoId
|
||||||
|
? queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.classDetectionLists(videoId),
|
||||||
|
})
|
||||||
|
: Promise.resolve(),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.overview(ticketId),
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.defectClasses(ticketId),
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to discard detection'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -171,42 +294,6 @@ export function useAssignTicketMutation(ticketId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSubmitRepairMutation(ticketId: string) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (payload: SubmitRepairPayload) =>
|
|
||||||
ticketService.submitRepair(ticketId, payload),
|
|
||||||
onSuccess: (ticket) => {
|
|
||||||
toast.success('Repair submitted');
|
|
||||||
queryClient.setQueryData<TicketDetail>(
|
|
||||||
ticketKeys.classDetail(ticketId, ticket.defect_class),
|
|
||||||
(current) => mergeTicketDetailResponse(current, ticket),
|
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
|
||||||
},
|
|
||||||
onError: () => toast.error('Failed to submit repair'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSubmitRepairUploadMutation(ticketId: string) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (payload: SubmitRepairUploadPayload) =>
|
|
||||||
ticketService.submitRepairUpload(ticketId, payload),
|
|
||||||
onSuccess: (ticket, payload) => {
|
|
||||||
toast.success('Repair evidence uploaded');
|
|
||||||
queryClient.setQueryData<TicketDetail>(
|
|
||||||
ticketKeys.classDetail(ticketId, payload.defect_class),
|
|
||||||
(current) => mergeTicketDetailResponse(current, ticket),
|
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
|
||||||
},
|
|
||||||
onError: () => toast.error('Failed to upload repair evidence'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRequestTicketExtensionMutation(
|
export function useRequestTicketExtensionMutation(
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
assignmentId: number | undefined,
|
assignmentId: number | undefined,
|
||||||
@@ -265,26 +352,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();
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,21 @@ export const ticketKeys = {
|
|||||||
[...ticketKeys.details(), ticketId, 'overview'] as const,
|
[...ticketKeys.details(), ticketId, 'overview'] as const,
|
||||||
classDetail: (ticketId: string, defectClass: string) =>
|
classDetail: (ticketId: string, defectClass: string) =>
|
||||||
[...ticketKeys.details(), ticketId, 'class', defectClass] as const,
|
[...ticketKeys.details(), ticketId, 'class', defectClass] as const,
|
||||||
|
classDetectionLists: (videoId: string) =>
|
||||||
|
[...ticketKeys.details(), 'video', videoId, 'detections'] as const,
|
||||||
classDetections: (videoId: string, params: VideoDetectionsParams) =>
|
classDetections: (videoId: string, params: VideoDetectionsParams) =>
|
||||||
[...ticketKeys.details(), 'video', videoId, 'detections', 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) =>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { AlertTriangle, ImageIcon, Loader2 } from 'lucide-react';
|
import { AlertTriangle, ImageIcon, Loader2 } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
|
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
|
||||||
import type { BoundingBoxOverlayItem, DetectionResultItem } from '@/types';
|
import type { BoundingBoxOverlayItem, DetectionResultItem } from '@/types';
|
||||||
|
|
||||||
@@ -12,13 +14,18 @@ interface AnnotatedDetectionImageProps {
|
|||||||
detection?: DetectionResultItem;
|
detection?: DetectionResultItem;
|
||||||
videoWidth: number;
|
videoWidth: number;
|
||||||
videoHeight: number;
|
videoHeight: number;
|
||||||
|
aspectRatio?: string;
|
||||||
|
enableLightbox?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AnnotatedDetectionImage({
|
export default function AnnotatedDetectionImage({
|
||||||
detection,
|
detection,
|
||||||
videoWidth,
|
videoWidth,
|
||||||
videoHeight,
|
videoHeight,
|
||||||
|
aspectRatio: aspectRatioOverride,
|
||||||
|
enableLightbox = false,
|
||||||
}: AnnotatedDetectionImageProps) {
|
}: AnnotatedDetectionImageProps) {
|
||||||
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(
|
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(
|
||||||
detection?.detection.context_image_url,
|
detection?.detection.context_image_url,
|
||||||
);
|
);
|
||||||
@@ -35,9 +42,65 @@ export default function AnnotatedDetectionImage({
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
const aspectRatio =
|
const aspectRatio =
|
||||||
videoWidth > 0 && videoHeight > 0
|
aspectRatioOverride ??
|
||||||
|
(videoWidth > 0 && videoHeight > 0
|
||||||
? `${videoWidth} / ${videoHeight}`
|
? `${videoWidth} / ${videoHeight}`
|
||||||
: '16 / 9';
|
: '16 / 9');
|
||||||
|
|
||||||
|
const annotatedMedia = objectUrl ? (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
src={objectUrl}
|
||||||
|
alt={`${detection?.detection.display_name ?? 'Road defect'} detection`}
|
||||||
|
fill
|
||||||
|
unoptimized
|
||||||
|
sizes="(min-width: 1280px) 18rem, (min-width: 640px) 50vw, 100vw"
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
<BoundingBoxOverlay
|
||||||
|
detections={overlayDetections}
|
||||||
|
videoWidth={videoWidth}
|
||||||
|
videoHeight={videoHeight}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (detection && objectUrl && enableLightbox) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsPreviewOpen(true)}
|
||||||
|
aria-label={`Open ${detection.detection.display_name} detection image`}
|
||||||
|
className="group relative flex w-full cursor-zoom-in items-center justify-center overflow-hidden rounded-lg border bg-black focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
style={{ aspectRatio }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={objectUrl}
|
||||||
|
alt={`${detection.detection.display_name} detection`}
|
||||||
|
fill
|
||||||
|
unoptimized
|
||||||
|
sizes="(min-width: 1280px) 18rem, (min-width: 640px) 50vw, 100vw"
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute inset-x-0 bottom-0 z-[3] bg-gradient-to-t from-black/70 to-transparent px-3 pb-2 pt-8 text-center text-xs font-medium text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||||
|
Click to view annotated image
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
|
||||||
|
<DialogContent className="h-[calc(100vh-2rem)] bg-black/95 p-4 text-white sm:max-w-[calc(100vw-2rem)]">
|
||||||
|
<DialogTitle className="sr-only">
|
||||||
|
{detection.detection.display_name} annotated detection image
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="relative min-h-0 w-full flex-1 overflow-hidden rounded-lg bg-black">
|
||||||
|
{annotatedMedia}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -60,20 +123,7 @@ export default function AnnotatedDetectionImage({
|
|||||||
<p>Failed to load the detection image.</p>
|
<p>Failed to load the detection image.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
annotatedMedia
|
||||||
<Image
|
|
||||||
src={objectUrl}
|
|
||||||
alt={`${detection.detection.display_name} detection`}
|
|
||||||
fill
|
|
||||||
unoptimized
|
|
||||||
className="object-contain"
|
|
||||||
/>
|
|
||||||
<BoundingBoxOverlay
|
|
||||||
detections={overlayDetections}
|
|
||||||
videoWidth={videoWidth}
|
|
||||||
videoHeight={videoHeight}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ export const API_ROUTES = {
|
|||||||
`/biz/api/v1/results/${id}/annotation-frames`,
|
`/biz/api/v1/results/${id}/annotation-frames`,
|
||||||
DETECTIONS: (id: string) => `/biz/api/v1/results/${id}/detections`,
|
DETECTIONS: (id: string) => `/biz/api/v1/results/${id}/detections`,
|
||||||
},
|
},
|
||||||
|
DETECTIONS: {
|
||||||
|
DISCARD: (id: number) => `/biz/api/v1/detections/${id}/discard`,
|
||||||
|
},
|
||||||
TICKETS: {
|
TICKETS: {
|
||||||
BASE: '/biz/api/v1/tickets',
|
BASE: '/biz/api/v1/tickets',
|
||||||
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
|
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
|
||||||
@@ -65,16 +68,14 @@ export const API_ROUTES = {
|
|||||||
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
|
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
|
||||||
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,
|
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,
|
||||||
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,
|
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,
|
||||||
SUBMIT_REPAIR: (id: string) => `/biz/api/v1/tickets/${id}/submit-repair`,
|
|
||||||
SUBMIT_REPAIR_UPLOAD: (id: string) =>
|
|
||||||
`/biz/api/v1/tickets/${id}/repair-proof`,
|
|
||||||
REQUEST_EXTENSION: (ticketId: string, assignmentId: number) =>
|
REQUEST_EXTENSION: (ticketId: string, assignmentId: number) =>
|
||||||
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-requests`,
|
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-requests`,
|
||||||
EXTENSION_REQUESTS: (ticketId: string) =>
|
EXTENSION_REQUESTS: (ticketId: string) =>
|
||||||
`/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`,
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { initializeAuthenticatedApp } from '@/services/initializer.service';
|
import { initializeAuthenticatedApp } from '@/services/initializer.service';
|
||||||
import { useAppStore } from '@/store/app.store';
|
import { useAppStore } from '@/store/app.store';
|
||||||
import { useAuthStore } from '@/store/auth.store';
|
import { useAuthStore } from '@/store/auth.store';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
export default function AppInitializer({ children }: { children: ReactNode }) {
|
export default function AppInitializer({ children }: { children: ReactNode }) {
|
||||||
const accessToken = useAuthStore((state) => state.accessToken);
|
const accessToken = useAuthStore((state) => state.accessToken);
|
||||||
const logout = useAuthStore((state) => state.logout);
|
|
||||||
const user = useAppStore((state) => state.user);
|
const user = useAppStore((state) => state.user);
|
||||||
|
const loadStatus = useAppStore((state) => state.loadStatus);
|
||||||
const setLoading = useAppStore((state) => state.setLoading);
|
const setLoading = useAppStore((state) => state.setLoading);
|
||||||
const setLoaded = useAppStore((state) => state.setLoaded);
|
const setLoaded = useAppStore((state) => state.setLoaded);
|
||||||
const setLoadError = useAppStore((state) => state.setLoadError);
|
const setLoadError = useAppStore((state) => state.setLoadError);
|
||||||
const clearApp = useAppStore((state) => state.clear);
|
const [retryAttempt, setRetryAttempt] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isActive = true;
|
let isActive = true;
|
||||||
@@ -30,8 +31,6 @@ export default function AppInitializer({ children }: { children: ReactNode }) {
|
|||||||
await initializeAuthenticatedApp();
|
await initializeAuthenticatedApp();
|
||||||
} catch {
|
} catch {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
logout();
|
|
||||||
clearApp();
|
|
||||||
setLoadError();
|
setLoadError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,15 +41,32 @@ export default function AppInitializer({ children }: { children: ReactNode }) {
|
|||||||
return () => {
|
return () => {
|
||||||
isActive = false;
|
isActive = false;
|
||||||
};
|
};
|
||||||
}, [
|
}, [accessToken, retryAttempt, setLoaded, setLoadError, setLoading, user]);
|
||||||
accessToken,
|
|
||||||
clearApp,
|
if (accessToken && !user && loadStatus === 'error') {
|
||||||
logout,
|
return (
|
||||||
setLoaded,
|
<main className="flex min-h-screen items-center justify-center bg-background px-6">
|
||||||
setLoadError,
|
<div
|
||||||
setLoading,
|
className="flex max-w-md flex-col items-center gap-4 text-center"
|
||||||
user,
|
role="alert"
|
||||||
]);
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-xl font-semibold">Unable to load workspace</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
We could not load your account details. Your session has been
|
||||||
|
kept, so you can safely try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRetryAttempt((value) => value + 1)}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/services/api/detection.service.ts
Normal file
36
src/services/api/detection.service.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||||
|
import type {
|
||||||
|
DiscardDetectionPayload,
|
||||||
|
DiscardDetectionResponse,
|
||||||
|
ReviewDetectionRepairProofPayload,
|
||||||
|
ReviewDetectionRepairProofResponse,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
import axiosClient from '../axios/axios';
|
||||||
|
|
||||||
|
export const detectionService = {
|
||||||
|
discardDetection: async (
|
||||||
|
detectionId: number,
|
||||||
|
payload: DiscardDetectionPayload,
|
||||||
|
): Promise<DiscardDetectionResponse> => {
|
||||||
|
const response = await axiosClient.post<DiscardDetectionResponse>(
|
||||||
|
API_ROUTES.DETECTIONS.DISCARD(detectionId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ export * from './package.service';
|
|||||||
export * from './auth.service';
|
export * from './auth.service';
|
||||||
export { chainageService } from './chainage.service';
|
export { chainageService } from './chainage.service';
|
||||||
export * from './video.service';
|
export * from './video.service';
|
||||||
|
export * from './detection.service';
|
||||||
export * from './permission.service';
|
export * from './permission.service';
|
||||||
export * from './role.service';
|
export * from './role.service';
|
||||||
export * from './user.service';
|
export * from './user.service';
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ import type {
|
|||||||
CloseTicketPayload,
|
CloseTicketPayload,
|
||||||
RequestTicketExtensionPayload,
|
RequestTicketExtensionPayload,
|
||||||
ReviewTicketExtensionPayload,
|
ReviewTicketExtensionPayload,
|
||||||
ReviewRepairPayload,
|
|
||||||
SseTokenResponse,
|
SseTokenResponse,
|
||||||
SubmitRepairPayload,
|
|
||||||
SubmitRepairUploadPayload,
|
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
TicketExtensionRequestsResponse,
|
TicketExtensionRequestsResponse,
|
||||||
TicketOverviewDetail,
|
TicketOverviewDetail,
|
||||||
@@ -91,41 +88,6 @@ export const ticketService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
submitRepair: async (
|
|
||||||
ticketId: string,
|
|
||||||
payload: SubmitRepairPayload,
|
|
||||||
): Promise<TicketDetail> => {
|
|
||||||
const response = await axiosClient.post<TicketDetail>(
|
|
||||||
API_ROUTES.TICKETS.SUBMIT_REPAIR(ticketId),
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
submitRepairUpload: async (
|
|
||||||
ticketId: string,
|
|
||||||
payload: SubmitRepairUploadPayload,
|
|
||||||
): Promise<TicketDetail> => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('defect_class', payload.defect_class);
|
|
||||||
formData.append('notes', payload.notes);
|
|
||||||
formData.append('video', payload.video);
|
|
||||||
payload.images.forEach((image) => {
|
|
||||||
formData.append('images', image);
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await axiosClient.post<TicketDetail>(
|
|
||||||
API_ROUTES.TICKETS.SUBMIT_REPAIR_UPLOAD(ticketId),
|
|
||||||
formData,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
requestTicketExtension: async (
|
requestTicketExtension: async (
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
assignmentId: number,
|
assignmentId: number,
|
||||||
@@ -162,17 +124,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,
|
||||||
|
|||||||
@@ -12,6 +12,62 @@ const axiosClient = axios.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const axiosAuth = axios.create({
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let refreshPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
|
const isInvalidSessionResponse = (error: unknown) => {
|
||||||
|
if (!axios.isAxiosError(error)) return false;
|
||||||
|
|
||||||
|
return error.response?.status === 401 || error.response?.status === 403;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshAccessToken = () => {
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = axiosAuth
|
||||||
|
.post(
|
||||||
|
'api/auth/refresh',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
const accessToken = response.data?.access_token;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('Refresh failed: no access token');
|
||||||
|
}
|
||||||
|
|
||||||
|
useAuthStore.getState().setAccessToken(accessToken);
|
||||||
|
|
||||||
|
return accessToken;
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (isInvalidSessionResponse(error)) {
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
useAppStore.getState().clear();
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshPromise;
|
||||||
|
};
|
||||||
|
|
||||||
axiosClient.interceptors.request.use(
|
axiosClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const token = useAuthStore.getState().accessToken;
|
const token = useAuthStore.getState().accessToken;
|
||||||
@@ -49,31 +105,11 @@ axiosClient.interceptors.response.use(
|
|||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axiosAuth.post(
|
const accessToken = await refreshAccessToken();
|
||||||
'api/auth/refresh',
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
withCredentials: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const accessToken = response.data?.access_token;
|
|
||||||
|
|
||||||
if (!accessToken) {
|
|
||||||
throw new Error('Refresh failed: no access token');
|
|
||||||
}
|
|
||||||
|
|
||||||
useAuthStore.getState().setAccessToken(accessToken);
|
|
||||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
|
||||||
return axiosClient(originalRequest);
|
return axiosClient(originalRequest);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
useAuthStore.getState().logout();
|
|
||||||
useAppStore.getState().clear();
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(refreshError);
|
return Promise.reject(refreshError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,11 +118,4 @@ axiosClient.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const axiosAuth = axios.create({
|
|
||||||
baseURL: BASE_URL,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default axiosClient;
|
export default axiosClient;
|
||||||
|
|||||||
@@ -22,6 +22,25 @@ export type BoundingBoxOverlayItem = {
|
|||||||
bounding_box: DetectionBoundingBox;
|
bounding_box: DetectionBoundingBox;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DetectionRepairProof = {
|
||||||
|
submitted: boolean;
|
||||||
|
review_status: 'pending' | 'approved' | 'rejected';
|
||||||
|
notes: string | null;
|
||||||
|
proof_image_urls: string[];
|
||||||
|
submitted_at: string | null;
|
||||||
|
reviewed_at: 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: {
|
||||||
@@ -42,6 +61,7 @@ export type DetectionResultItem = {
|
|||||||
latitude: number | null;
|
latitude: number | null;
|
||||||
longitude: number | null;
|
longitude: number | null;
|
||||||
};
|
};
|
||||||
|
repair_proof: DetectionRepairProof | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DetectionResultLog = DetectionResultItem & {
|
export type DetectionResultLog = DetectionResultItem & {
|
||||||
@@ -59,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;
|
||||||
@@ -75,6 +103,30 @@ export type VideoDetectionsResponse = {
|
|||||||
items: DetectionResultItem[];
|
items: DetectionResultItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DetectionDiscardReason =
|
||||||
|
| 'not_a_defect'
|
||||||
|
| 'wrong_class'
|
||||||
|
| 'duplicate'
|
||||||
|
| 'poor_image_quality'
|
||||||
|
| 'incorrect_location'
|
||||||
|
| 'already_repaired'
|
||||||
|
| 'other';
|
||||||
|
|
||||||
|
export type DiscardDetectionPayload = {
|
||||||
|
reason: DetectionDiscardReason;
|
||||||
|
comment?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiscardDetectionResponse = {
|
||||||
|
detection_id: number;
|
||||||
|
video_id: string;
|
||||||
|
status: 'discarded';
|
||||||
|
feedback_id: number;
|
||||||
|
action: 'discarded';
|
||||||
|
ticket_detection_count: number;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type VideoAnnotationFramesParams = {
|
export type VideoAnnotationFramesParams = {
|
||||||
start_time_ms: number;
|
start_time_ms: number;
|
||||||
end_time_ms: number;
|
end_time_ms: number;
|
||||||
|
|||||||
@@ -29,18 +29,6 @@ export interface AssignTicketPayload {
|
|||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmitRepairPayload {
|
|
||||||
notes: string;
|
|
||||||
proof_path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubmitRepairUploadPayload {
|
|
||||||
defect_class: string;
|
|
||||||
notes: string;
|
|
||||||
video: File;
|
|
||||||
images: File[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RequestTicketExtensionPayload {
|
export interface RequestTicketExtensionPayload {
|
||||||
requested_due_at: string;
|
requested_due_at: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
@@ -80,12 +68,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