Compare commits
16 Commits
feat/disca
...
e8f2955acf
| Author | SHA1 | Date | |
|---|---|---|---|
| e8f2955acf | |||
| 5e66b70611 | |||
| 7ffe3f0708 | |||
| 8e8803396d | |||
| 0601322361 | |||
| 20f611f35a | |||
| 224361f144 | |||
| 77b6755d05 | |||
| 2f25fd49be | |||
| 3891500cf2 | |||
| 062e89fcba | |||
| 725c256791 | |||
| 5d3dac0b21 | |||
| e077467bce | |||
| e94da54aa9 | |||
| 76c87811d8 |
@@ -1,2 +1,2 @@
|
||||
# Backend API base URL (no trailing slash)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||
# Server-only backend origin. Do not expose this as a NEXT_PUBLIC variable.
|
||||
BACKEND_ORIGIN=http://localhost:8000
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const backendOrigin = process.env.BACKEND_ORIGIN?.replace(/\/+$/, '');
|
||||
|
||||
if (!backendOrigin) {
|
||||
throw new Error('BACKEND_ORIGIN is required');
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: false,
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/:namespace(api|biz)/:path*',
|
||||
destination: `${backendOrigin}/:namespace/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
5
public/color_logo.svg
Normal file
5
public/color_logo.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="#372AAC"/>
|
||||
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8026C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0642C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="#372AAC"/>
|
||||
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="#372AAC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
5
public/logo.svg
Normal file
5
public/logo.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="white"/>
|
||||
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8025C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0641C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="white"/>
|
||||
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -13,11 +13,11 @@ const ModulesLayout = ({
|
||||
<AuthGuard>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<main className="flex flex-1 h-screen w-full flex-col overflow-hidden">
|
||||
<div className="scroll-stable flex-1 overflow-auto">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-380 flex-col">
|
||||
<main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="scroll-stable min-w-0 flex-1 overflow-auto">
|
||||
<div className="mx-auto flex min-h-full w-full min-w-0 max-w-380 flex-col">
|
||||
<ModuleShellHeader />
|
||||
<div className="flex-1 p-6">{children}</div>
|
||||
<div className="min-w-0 flex-1 p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,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,244 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowRight,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
|
||||
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { AssignableTicketUser } from '@/types';
|
||||
|
||||
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
|
||||
import {
|
||||
formatCompactRangeCoordinate,
|
||||
formatRangeCoordinate,
|
||||
type AssignmentRangePoint,
|
||||
} from './assignmentRange';
|
||||
|
||||
interface AssignTicketFormProps {
|
||||
ticketId: string;
|
||||
videoId?: string;
|
||||
selectedClassNames: string[];
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onAssigned: () => void;
|
||||
}
|
||||
|
||||
export function AssignTicketForm({
|
||||
ticketId,
|
||||
videoId,
|
||||
selectedClassNames,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onAssigned,
|
||||
}: AssignTicketFormProps) {
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
const [selectedContractor, setSelectedContractor] =
|
||||
useState<AssignableTicketUser | null>(null);
|
||||
const [dueDate, setDueDate] = useState<Date>();
|
||||
const [assignNote, setAssignNote] = useState('');
|
||||
|
||||
const assignMutation = useAssignTicketMutation(ticketId, videoId);
|
||||
const today = useMemo(() => {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}, []);
|
||||
const maxDueMonth = useMemo(
|
||||
() => new Date(today.getFullYear() + 20, 11),
|
||||
[today],
|
||||
);
|
||||
const hasIncompleteLocationRange = Boolean(startPoint) !== Boolean(endPoint);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Users className="text-primary" />
|
||||
Contractor and schedule
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Contractor <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<AssignableWorkerSelect
|
||||
value={selectedUserId}
|
||||
onValueChange={(userId, user) => {
|
||||
setSelectedUserId(userId);
|
||||
setSelectedContractor(user);
|
||||
}}
|
||||
disabled={assignMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedContractor ? (
|
||||
<div className="space-y-2 rounded-lg border bg-muted/30 p-4">
|
||||
<p className="text-sm font-medium">Contractor details</p>
|
||||
{selectedContractor.phone_number ? (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Phone className="size-4 shrink-0" />
|
||||
{selectedContractor.phone_number}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Mail className="size-4 shrink-0" />
|
||||
<span className="truncate">{selectedContractor.email}</span>
|
||||
</p>
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ShieldCheck className="size-4 shrink-0" />
|
||||
{selectedContractor.role.name}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{startPoint || endPoint ? (
|
||||
<div className="space-y-2">
|
||||
<Label>Location</Label>
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-2 rounded-lg border px-3 py-2 text-xs"
|
||||
title={[
|
||||
startPoint ? formatRangeCoordinate(startPoint) : null,
|
||||
endPoint ? formatRangeCoordinate(endPoint) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' to ')}
|
||||
>
|
||||
<MapPin className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 font-mono">
|
||||
{startPoint
|
||||
? formatCompactRangeCoordinate(startPoint)
|
||||
: endPoint
|
||||
? formatCompactRangeCoordinate(endPoint)
|
||||
: null}
|
||||
</span>
|
||||
{startPoint && endPoint ? (
|
||||
<>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 font-mono">
|
||||
{formatCompactRangeCoordinate(endPoint)}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{hasIncompleteLocationRange ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{startPoint
|
||||
? 'Select an end location to complete the range.'
|
||||
: 'Select a start location to complete the range.'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="assign-due-date">
|
||||
Due date <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<DatePickerSimple
|
||||
id="assign-due-date"
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
showLabel={false}
|
||||
placeholder="Select due date"
|
||||
disabled={assignMutation.isPending}
|
||||
className="w-full"
|
||||
startMonth={today}
|
||||
endMonth={maxDueMonth}
|
||||
disabledDates={{ before: today }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="assign-note">Assignment note (optional)</Label>
|
||||
<Textarea
|
||||
id="assign-note"
|
||||
value={assignNote}
|
||||
onChange={(event) => setAssignNote(event.target.value)}
|
||||
placeholder="Add instructions or notes for the contractor..."
|
||||
disabled={assignMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
!selectedUserId ||
|
||||
!selectedContractor ||
|
||||
!dueDate ||
|
||||
hasIncompleteLocationRange ||
|
||||
assignMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
!selectedUserId ||
|
||||
!selectedContractor ||
|
||||
!dueDate ||
|
||||
hasIncompleteLocationRange
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const note = assignNote.trim() || undefined;
|
||||
const hasCriteria =
|
||||
selectedClassNames.length > 0 ||
|
||||
Boolean(startPoint && endPoint);
|
||||
|
||||
assignMutation.mutate(
|
||||
{
|
||||
assigned_to_user_id: Number(selectedUserId),
|
||||
assigned_to_email: selectedContractor.email,
|
||||
due_at: dueDate.toISOString(),
|
||||
note,
|
||||
...(hasCriteria
|
||||
? {
|
||||
criteria: {
|
||||
...(selectedClassNames.length > 0
|
||||
? { class_names: selectedClassNames }
|
||||
: {}),
|
||||
...(startPoint && endPoint
|
||||
? {
|
||||
start_lat: startPoint.latitude,
|
||||
start_lng: startPoint.longitude,
|
||||
end_lat: endPoint.latitude,
|
||||
end_lng: endPoint.longitude,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelectedUserId('');
|
||||
setSelectedContractor(null);
|
||||
setDueDate(undefined);
|
||||
setAssignNote('');
|
||||
onAssigned();
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Send />
|
||||
{assignMutation.isPending ? 'Assigning...' : 'Assign ticket'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { AlertTriangle, MapPinned, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import type {
|
||||
DetectionCoordinateClass,
|
||||
DetectionCoordinateItem,
|
||||
DetectionCoordinatesResponse,
|
||||
} from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
import type { AssignmentRangePoint } from './assignmentRange';
|
||||
import { AssignmentRangeActions } from './AssignmentRangeActions';
|
||||
|
||||
type AssignmentIssuesMapProps = {
|
||||
data?: DetectionCoordinatesResponse;
|
||||
isLoading: boolean;
|
||||
isFetchingMore: boolean;
|
||||
isError: boolean;
|
||||
hasMore: boolean;
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onSetStart: (point: AssignmentRangePoint) => void;
|
||||
onSetEnd: (point: AssignmentRangePoint) => void;
|
||||
onLoadMore: () => void;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
type ClientAssignmentIssuesMapProps = {
|
||||
items: DetectionCoordinateItem[];
|
||||
classes: DetectionCoordinateClass[];
|
||||
selectedDetectionId: number | null;
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onSelectDetection: (detectionId: number) => void;
|
||||
onSetStart: (point: AssignmentRangePoint) => void;
|
||||
onSetEnd: (point: AssignmentRangePoint) => void;
|
||||
};
|
||||
|
||||
function isValidCoordinate(item: DetectionCoordinateItem) {
|
||||
return (
|
||||
Number.isFinite(item.latitude) &&
|
||||
Number.isFinite(item.longitude) &&
|
||||
item.latitude >= -90 &&
|
||||
item.latitude <= 90 &&
|
||||
item.longitude >= -180 &&
|
||||
item.longitude <= 180
|
||||
);
|
||||
}
|
||||
|
||||
const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
||||
async () => {
|
||||
const { CircleMarker, MapContainer, Popup, TileLayer, Tooltip, useMap } =
|
||||
await import('react-leaflet');
|
||||
const { canvas } = await import('leaflet');
|
||||
|
||||
function FitMapToIssues({ items }: { items: DetectionCoordinateItem[] }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length === 0) return;
|
||||
|
||||
if (items.length === 1) {
|
||||
map.setView([items[0].latitude, items[0].longitude], 17);
|
||||
return;
|
||||
}
|
||||
|
||||
map.fitBounds(
|
||||
items.map(
|
||||
(item) => [item.latitude, item.longitude] as [number, number],
|
||||
),
|
||||
{
|
||||
padding: [32, 32],
|
||||
maxZoom: 17,
|
||||
},
|
||||
);
|
||||
}, [items, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return function ClientAssignmentIssuesMapInner({
|
||||
items,
|
||||
classes,
|
||||
selectedDetectionId,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSelectDetection,
|
||||
onSetStart,
|
||||
onSetEnd,
|
||||
}: ClientAssignmentIssuesMapProps) {
|
||||
const markerRenderer = useMemo(() => canvas({ tolerance: 12 }), []);
|
||||
const displayNames = new Map(
|
||||
classes.map((item) => [item.class_name, item.display_name]),
|
||||
);
|
||||
const initialCenter: [number, number] = [
|
||||
items[0].latitude,
|
||||
items[0].longitude,
|
||||
];
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={initialCenter}
|
||||
zoom={15}
|
||||
scrollWheelZoom
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TileLayer
|
||||
attribution="© OpenStreetMap contributors"
|
||||
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<FitMapToIssues items={items} />
|
||||
|
||||
{items.map((item) => {
|
||||
const visual = getDefectVisual(item.class_name);
|
||||
const IssueIcon = visual.icon;
|
||||
const isSelected = selectedDetectionId === item.id;
|
||||
const isStart = startPoint?.detectionId === item.id;
|
||||
const isEnd = endPoint?.detectionId === item.id;
|
||||
const rangeLabel = isStart ? 'A' : isEnd ? 'B' : null;
|
||||
const rangeColor = isStart
|
||||
? '#16a34a'
|
||||
: isEnd
|
||||
? '#dc2626'
|
||||
: visual.boundingBoxColor;
|
||||
const isRangeIssue = isStart || isEnd;
|
||||
const displayName =
|
||||
displayNames.get(item.class_name) ??
|
||||
item.class_name.replaceAll('_', ' ');
|
||||
const point: AssignmentRangePoint = {
|
||||
detectionId: item.id,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
};
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={item.id}
|
||||
center={[item.latitude, item.longitude]}
|
||||
renderer={markerRenderer}
|
||||
radius={isSelected || isRangeIssue ? 11 : 8}
|
||||
pathOptions={{
|
||||
color:
|
||||
isSelected || isRangeIssue
|
||||
? '#ffffff'
|
||||
: visual.boundingBoxColor,
|
||||
fillColor: rangeColor,
|
||||
fillOpacity: isSelected || isRangeIssue ? 1 : 0.82,
|
||||
weight: isSelected || isRangeIssue ? 4 : 2,
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: () => onSelectDetection(item.id),
|
||||
}}
|
||||
>
|
||||
<Popup minWidth={240} maxWidth={280}>
|
||||
<div className="w-60">
|
||||
<div className="flex items-start gap-3 border-b pb-3 pr-4">
|
||||
<span
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${visual.boundingBoxColor}1f`,
|
||||
color: visual.boundingBoxColor,
|
||||
}}
|
||||
>
|
||||
<IssueIcon className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="!m-0 truncate text-sm font-semibold leading-5">
|
||||
{displayName}
|
||||
</p>
|
||||
<p className="!m-0 text-xs leading-4 text-muted-foreground">
|
||||
Detection #{item.id}
|
||||
</p>
|
||||
</div>
|
||||
{rangeLabel ? (
|
||||
<span
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: rangeColor }}
|
||||
aria-label={isStart ? 'Start issue' : 'End issue'}
|
||||
>
|
||||
{rangeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<dl className="!m-0 space-y-2 py-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-muted-foreground">Confidence</dt>
|
||||
<dd className="!m-0 font-medium tabular-nums">
|
||||
{Math.round(item.confidence * 100)}%
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-muted-foreground">Latitude</dt>
|
||||
<dd className="!m-0 font-mono text-xs tabular-nums">
|
||||
{item.latitude.toFixed(6)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-muted-foreground">Longitude</dt>
|
||||
<dd className="!m-0 font-mono text-xs tabular-nums">
|
||||
{item.longitude.toFixed(6)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<AssignmentRangeActions
|
||||
point={point}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onSetStart={onSetStart}
|
||||
onSetEnd={onSetEnd}
|
||||
stretch
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
{rangeLabel ? (
|
||||
<Tooltip permanent direction="center" opacity={1}>
|
||||
{rangeLabel}
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
);
|
||||
};
|
||||
},
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="size-full animate-pulse bg-muted" />,
|
||||
},
|
||||
);
|
||||
|
||||
export function AssignmentIssuesMap({
|
||||
data,
|
||||
isLoading,
|
||||
isFetchingMore,
|
||||
isError,
|
||||
hasMore,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSetStart,
|
||||
onSetEnd,
|
||||
onLoadMore,
|
||||
onRetry,
|
||||
}: AssignmentIssuesMapProps) {
|
||||
const [selectedDetectionId, setSelectedDetectionId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const validItems = useMemo(
|
||||
() => (data?.items ?? []).filter(isValidCoordinate),
|
||||
[data?.items],
|
||||
);
|
||||
const total = data?.total ?? 0;
|
||||
const loadedCount = Math.min(data?.items.length ?? 0, total);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedDetectionId !== null &&
|
||||
!validItems.some((item) => item.id === selectedDetectionId)
|
||||
) {
|
||||
setSelectedDetectionId(null);
|
||||
}
|
||||
}, [selectedDetectionId, validItems]);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-secondary p-2">
|
||||
<MapPinned className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">Issues on Map</CardTitle>
|
||||
<CardDescription>
|
||||
Click a marker, then choose Start or End.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{data ? (
|
||||
<span className="shrink-0 text-sm text-muted-foreground">
|
||||
{validItems.length} mapped
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="h-[440px] animate-pulse bg-muted" />
|
||||
) : isError ? (
|
||||
<div className="flex min-h-72 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<AlertTriangle className="size-7 text-destructive" />
|
||||
<div>
|
||||
<p className="font-medium">Unable to load issue locations.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Check the connection and try again.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={onRetry}>
|
||||
<RefreshCw />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : validItems.length === 0 ? (
|
||||
<div className="flex min-h-72 flex-col items-center justify-center px-6 text-center">
|
||||
<MapPinned className="size-7 text-muted-foreground" />
|
||||
<p className="mt-3 font-medium">No issue locations available.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Detections without valid coordinates cannot be placed on the map.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-[440px] min-w-0 bg-muted">
|
||||
<ClientAssignmentIssuesMap
|
||||
items={validItems}
|
||||
classes={data?.classes ?? []}
|
||||
selectedDetectionId={selectedDetectionId}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onSelectDetection={setSelectedDetectionId}
|
||||
onSetStart={onSetStart}
|
||||
onSetEnd={onSetEnd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-4 gap-y-2 border-t px-4 py-3"
|
||||
aria-label="Map legend"
|
||||
>
|
||||
<span className="text-sm font-medium">Map legend</span>
|
||||
{(data?.classes ?? []).map((item) => {
|
||||
const visual = getDefectVisual(item.class_name);
|
||||
const IssueIcon = visual.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.class_name}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
className="flex size-6 items-center justify-center rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${visual.boundingBoxColor}1f`,
|
||||
color: visual.boundingBoxColor,
|
||||
}}
|
||||
>
|
||||
<IssueIcon className="size-3.5" />
|
||||
</span>
|
||||
<span>{item.display_name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t px-4 py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Showing {loadedCount} of {total}
|
||||
</span>
|
||||
{hasMore ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onLoadMore}
|
||||
disabled={isFetchingMore}
|
||||
>
|
||||
{isFetchingMore ? 'Loading...' : 'Load more issues'}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
All issues are shown
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { AssignmentRangePoint } from './assignmentRange';
|
||||
|
||||
interface AssignmentRangeActionsProps {
|
||||
point: AssignmentRangePoint;
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onSetStart: (point: AssignmentRangePoint) => void;
|
||||
onSetEnd: (point: AssignmentRangePoint) => void;
|
||||
stretch?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AssignmentRangeActions({
|
||||
point,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSetStart,
|
||||
onSetEnd,
|
||||
stretch = false,
|
||||
className,
|
||||
}: AssignmentRangeActionsProps) {
|
||||
const isStart = point.detectionId === startPoint?.detectionId;
|
||||
const isEnd = point.detectionId === endPoint?.detectionId;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={isStart ? 'secondary' : 'outline'}
|
||||
className={cn(stretch && 'flex-1')}
|
||||
disabled={isStart}
|
||||
aria-pressed={isStart}
|
||||
title={isStart ? 'Selected as start' : 'Set as start'}
|
||||
onClick={() => onSetStart(point)}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={isEnd ? 'secondary' : 'outline'}
|
||||
className={cn(stretch && 'flex-1')}
|
||||
disabled={startPoint === null || isStart || isEnd}
|
||||
aria-pressed={isEnd}
|
||||
title={
|
||||
startPoint === null
|
||||
? 'Select a start issue first'
|
||||
: isStart
|
||||
? 'Choose another issue for the end'
|
||||
: isEnd
|
||||
? 'Selected as end'
|
||||
: 'Set as end'
|
||||
}
|
||||
onClick={() => onSetEnd(point)}
|
||||
>
|
||||
End
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { MultiSelectPopover } from '@/components/form/MultiSelectPopover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
import {
|
||||
useDetectionCoordinatesQuery,
|
||||
useTicketDetectionsQuery,
|
||||
} from '../../../hooks/useTicketQueries';
|
||||
import {
|
||||
formatCompactRangeCoordinate,
|
||||
formatRangeCoordinate,
|
||||
type AssignmentRangePoint,
|
||||
} from './assignmentRange';
|
||||
import { AssignmentIssuesMap } from './AssignmentIssuesMap';
|
||||
import { useIssueColumns } from './IssueColumns';
|
||||
import { IssueTable } from './IssueTable';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
const MAP_PAGE_SIZE = 50;
|
||||
|
||||
interface IssueTypeOption {
|
||||
class_name: string;
|
||||
display_name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ChooseIssuesSectionProps {
|
||||
videoId?: string;
|
||||
issueTypes: IssueTypeOption[];
|
||||
selectedIssueTypes: string[];
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onSelectedIssueTypesChange: (values: string[]) => void;
|
||||
onStartPointChange: (point: AssignmentRangePoint | null) => void;
|
||||
onEndPointChange: (point: AssignmentRangePoint | null) => void;
|
||||
}
|
||||
|
||||
export function ChooseIssuesSection({
|
||||
videoId,
|
||||
issueTypes,
|
||||
selectedIssueTypes,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSelectedIssueTypesChange,
|
||||
onStartPointChange,
|
||||
onEndPointChange,
|
||||
}: ChooseIssuesSectionProps) {
|
||||
const [skip, setSkip] = useState(0);
|
||||
const [limit, setLimit] = useState(DEFAULT_PAGE_SIZE);
|
||||
const handleSetStart = useCallback(
|
||||
(point: AssignmentRangePoint) => {
|
||||
onStartPointChange(point);
|
||||
onEndPointChange(null);
|
||||
},
|
||||
[onEndPointChange, onStartPointChange],
|
||||
);
|
||||
const handleSetEnd = useCallback(
|
||||
(point: AssignmentRangePoint) => {
|
||||
if (point.detectionId === startPoint?.detectionId) return;
|
||||
|
||||
onEndPointChange(point);
|
||||
},
|
||||
[onEndPointChange, startPoint?.detectionId],
|
||||
);
|
||||
const columns = useIssueColumns({
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSetStart: handleSetStart,
|
||||
onSetEnd: handleSetEnd,
|
||||
});
|
||||
const issueTypeOptions = useMemo(
|
||||
() =>
|
||||
issueTypes.map((issue) => ({
|
||||
value: issue.class_name,
|
||||
label: issue.display_name,
|
||||
})),
|
||||
[issueTypes],
|
||||
);
|
||||
const handleIssueTypesChange = useCallback(
|
||||
(values: string[]) => {
|
||||
onSelectedIssueTypesChange(values);
|
||||
onStartPointChange(null);
|
||||
onEndPointChange(null);
|
||||
},
|
||||
[onEndPointChange, onSelectedIssueTypesChange, onStartPointChange],
|
||||
);
|
||||
useEffect(() => {
|
||||
setSkip(0);
|
||||
}, [selectedIssueTypes]);
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
skip,
|
||||
limit,
|
||||
class_name:
|
||||
selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined,
|
||||
sort: 'timestamp_asc',
|
||||
}),
|
||||
[limit, selectedIssueTypes, skip],
|
||||
);
|
||||
const detectionsQuery = useTicketDetectionsQuery(videoId, queryParams);
|
||||
const mapQueryParams = useMemo(
|
||||
() => ({
|
||||
limit: MAP_PAGE_SIZE,
|
||||
class_name:
|
||||
selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined,
|
||||
assignment_status: 'unassigned' as const,
|
||||
}),
|
||||
[selectedIssueTypes],
|
||||
);
|
||||
const coordinatesQuery = useDetectionCoordinatesQuery(
|
||||
videoId,
|
||||
mapQueryParams,
|
||||
);
|
||||
const result = detectionsQuery.data;
|
||||
const mapData = useMemo(() => {
|
||||
const pages = coordinatesQuery.data?.pages;
|
||||
|
||||
if (!pages?.length) return undefined;
|
||||
|
||||
const latestPage = pages[pages.length - 1];
|
||||
|
||||
return {
|
||||
...latestPage,
|
||||
items: pages.flatMap((page) => page.items),
|
||||
truncated: Boolean(coordinatesQuery.hasNextPage),
|
||||
};
|
||||
}, [coordinatesQuery.data?.pages, coordinatesQuery.hasNextPage]);
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-5">
|
||||
<Card size="sm" aria-label="Issue filters">
|
||||
<CardHeader>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<MultiSelectPopover
|
||||
label="Issue Types"
|
||||
options={issueTypeOptions}
|
||||
values={selectedIssueTypes}
|
||||
onValuesChange={handleIssueTypesChange}
|
||||
searchPlaceholder="Search issue types"
|
||||
emptyMessage="No issue types found."
|
||||
emptySelectionLabel="All"
|
||||
align="start"
|
||||
/>
|
||||
|
||||
{startPoint ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-8 gap-1.5 px-2.5 font-normal"
|
||||
title={formatRangeCoordinate(startPoint)}
|
||||
>
|
||||
<span className="font-medium">Start</span>
|
||||
<span className="font-mono">
|
||||
{formatCompactRangeCoordinate(startPoint)}
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
{endPoint ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-8 gap-1.5 px-2.5 font-normal"
|
||||
title={formatRangeCoordinate(endPoint)}
|
||||
>
|
||||
<span className="font-medium">End</span>
|
||||
<span className="font-mono">
|
||||
{formatCompactRangeCoordinate(endPoint)}
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
{startPoint !== null || endPoint !== null ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
aria-label="Clear coordinate range"
|
||||
onClick={() => {
|
||||
onStartPointChange(null);
|
||||
onEndPointChange(null);
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AssignmentIssuesMap
|
||||
data={mapData}
|
||||
isLoading={coordinatesQuery.isLoading}
|
||||
isFetchingMore={coordinatesQuery.isFetchingNextPage}
|
||||
isError={coordinatesQuery.isError}
|
||||
hasMore={Boolean(coordinatesQuery.hasNextPage)}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onSetStart={handleSetStart}
|
||||
onSetEnd={handleSetEnd}
|
||||
onLoadMore={() => void coordinatesQuery.fetchNextPage()}
|
||||
onRetry={() => void coordinatesQuery.refetch()}
|
||||
/>
|
||||
|
||||
<IssueTable
|
||||
columns={columns}
|
||||
issues={result?.items ?? []}
|
||||
isLoading={detectionsQuery.isLoading}
|
||||
isError={detectionsQuery.isError}
|
||||
toolbar={null}
|
||||
skip={skip}
|
||||
limit={limit}
|
||||
total={result?.total ?? 0}
|
||||
onPageChange={setSkip}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import type { DetectionResultItem } from '@/types';
|
||||
|
||||
import type { AssignmentRangePoint } from './assignmentRange';
|
||||
import { AssignmentRangeActions } from './AssignmentRangeActions';
|
||||
|
||||
function formatTimestamp(seconds: number) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface UseIssueColumnsOptions {
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onSetStart: (point: AssignmentRangePoint) => void;
|
||||
onSetEnd: (point: AssignmentRangePoint) => void;
|
||||
}
|
||||
|
||||
export function useIssueColumns({
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSetStart,
|
||||
onSetEnd,
|
||||
}: UseIssueColumnsOptions): ColumnDef<DetectionResultItem>[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'detection_id',
|
||||
header: 'Detection ID',
|
||||
size: 140,
|
||||
cell: ({ row }) => `#${row.original.detection.id}`,
|
||||
},
|
||||
{
|
||||
id: 'issue_type',
|
||||
header: 'Issue Type',
|
||||
size: 180,
|
||||
cell: ({ row }) => {
|
||||
const { class_name: className, display_name: displayName } =
|
||||
row.original.detection;
|
||||
const visual = getDefectVisual(className);
|
||||
const IssueIcon = visual.icon;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 gap-1.5 px-2 font-normal"
|
||||
style={{
|
||||
borderColor: `${visual.boundingBoxColor}66`,
|
||||
backgroundColor: `${visual.boundingBoxColor}14`,
|
||||
color: visual.boundingBoxColor,
|
||||
}}
|
||||
>
|
||||
<IssueIcon />
|
||||
{displayName}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'coordinates',
|
||||
header: 'Coordinates',
|
||||
size: 220,
|
||||
cell: ({ row }) => {
|
||||
const issueId = row.original.detection.id;
|
||||
const { latitude, longitude } = row.original.location;
|
||||
|
||||
if (latitude === null || longitude === null) {
|
||||
return <span className="text-muted-foreground">Unavailable</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="font-mono text-xs">
|
||||
{latitude.toFixed(6)}, {longitude.toFixed(6)}
|
||||
</p>
|
||||
{issueId === startPoint?.detectionId ? (
|
||||
<p className="text-xs font-medium text-green-600">Start</p>
|
||||
) : issueId === endPoint?.detectionId ? (
|
||||
<p className="text-xs font-medium text-red-600">End</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'timestamp',
|
||||
header: 'Timestamp',
|
||||
size: 130,
|
||||
cell: ({ row }) =>
|
||||
formatTimestamp(row.original.frame.timestamp_seconds),
|
||||
},
|
||||
{
|
||||
id: 'range_actions',
|
||||
header: 'Set range',
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const detectionId = row.original.detection.id;
|
||||
const { latitude, longitude } = row.original.location;
|
||||
const hasValidCoordinate =
|
||||
latitude !== null &&
|
||||
longitude !== null &&
|
||||
Number.isFinite(latitude) &&
|
||||
Number.isFinite(longitude) &&
|
||||
latitude >= -90 &&
|
||||
latitude <= 90 &&
|
||||
longitude >= -180 &&
|
||||
longitude <= 180;
|
||||
|
||||
if (!hasValidCoordinate) {
|
||||
return <span className="text-muted-foreground">Unavailable</span>;
|
||||
}
|
||||
|
||||
const point: AssignmentRangePoint = {
|
||||
detectionId,
|
||||
latitude,
|
||||
longitude,
|
||||
};
|
||||
return (
|
||||
<AssignmentRangeActions
|
||||
point={point}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onSetStart={onSetStart}
|
||||
onSetEnd={onSetEnd}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[endPoint, onSetEnd, onSetStart, startPoint],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import type { DetectionResultItem } from '@/types';
|
||||
|
||||
interface IssueTableProps {
|
||||
columns: ColumnDef<DetectionResultItem>[];
|
||||
issues: DetectionResultItem[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
toolbar: ReactNode;
|
||||
skip: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
onPageChange: (skip: number) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
}
|
||||
|
||||
export function IssueTable({
|
||||
columns,
|
||||
issues,
|
||||
isLoading,
|
||||
isError,
|
||||
toolbar,
|
||||
skip,
|
||||
limit,
|
||||
total,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}: IssueTableProps) {
|
||||
return (
|
||||
<DataTable
|
||||
title="Issue details"
|
||||
columns={columns}
|
||||
data={issues}
|
||||
isLoading={isLoading}
|
||||
toolbar={toolbar}
|
||||
emptyTitle={isError ? 'Failed to load issues.' : 'No issues found.'}
|
||||
emptyDescription={
|
||||
isError
|
||||
? 'Please try again.'
|
||||
: 'No detections match the current filters.'
|
||||
}
|
||||
pagination={{
|
||||
skip,
|
||||
limit,
|
||||
totalItems: total,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface AssignmentRangePoint {
|
||||
detectionId: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export function formatRangeCoordinate(point: AssignmentRangePoint) {
|
||||
return `${point.latitude.toFixed(6)}, ${point.longitude.toFixed(6)}`;
|
||||
}
|
||||
|
||||
export function formatCompactRangeCoordinate(point: AssignmentRangePoint) {
|
||||
return `${point.latitude.toFixed(4)}, ${point.longitude.toFixed(4)}`;
|
||||
}
|
||||
161
src/app/(modules)/ticket/[ticketId]/assign/page.tsx
Normal file
161
src/app/(modules)/ticket/[ticketId]/assign/page.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, ArrowLeft, LockKeyhole } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
import { useTicketOverviewQuery } from '../../hooks/useTicketQueries';
|
||||
import type { AssignmentRangePoint } from './components/assignmentRange';
|
||||
import { AssignTicketForm } from './components/AssignTicketForm';
|
||||
import { ChooseIssuesSection } from './components/ChooseIssuesSection';
|
||||
|
||||
function AssignmentPageSkeleton() {
|
||||
return (
|
||||
<div className="space-y-5" aria-label="Loading assignment workspace">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-lg" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-56" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<Skeleton className="h-[540px] rounded-xl" />
|
||||
<Skeleton className="h-[430px] rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TicketAssignmentPage() {
|
||||
const router = useRouter();
|
||||
const { ticketId } = useParams() as { ticketId: string };
|
||||
const [startPoint, setStartPoint] = useState<AssignmentRangePoint | null>(
|
||||
null,
|
||||
);
|
||||
const [endPoint, setEndPoint] = useState<AssignmentRangePoint | null>(null);
|
||||
const [selectedIssueTypes, setSelectedIssueTypes] = useState<string[]>([]);
|
||||
const overviewQuery = useTicketOverviewQuery(ticketId);
|
||||
const overview = overviewQuery.data;
|
||||
const videoId = overview?.video_id ?? overview?.video?.id ?? undefined;
|
||||
const ticketDetailUrl = ROUTES.TICKET_DETAIL(ticketId);
|
||||
const backToTicket = () => router.push(ticketDetailUrl);
|
||||
|
||||
if (overviewQuery.isLoading) {
|
||||
return <AssignmentPageSkeleton />;
|
||||
}
|
||||
|
||||
if (overviewQuery.isError || !overview) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
|
||||
<AlertTriangle className="size-8 text-destructive" />
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
Unable to open the assignment workspace.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Ticket information is unavailable.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={backToTicket}>
|
||||
Back to ticket
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void overviewQuery.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const ticketLabel = overview.ticket_name || overview.id;
|
||||
|
||||
return (
|
||||
<main className="relative z-10 space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
|
||||
<header className="flex items-start gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={backToTicket}
|
||||
aria-label="Back to ticket"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Assign ticket
|
||||
</h1>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
Ticket: {ticketLabel}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid min-w-0 items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-stretch xl:overflow-hidden">
|
||||
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
<ChooseIssuesSection
|
||||
videoId={videoId}
|
||||
issueTypes={overview.ai_result.detections_by_class}
|
||||
selectedIssueTypes={selectedIssueTypes}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onSelectedIssueTypesChange={setSelectedIssueTypes}
|
||||
onStartPointChange={setStartPoint}
|
||||
onEndPointChange={setEndPoint}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||
fallback={
|
||||
<Card>
|
||||
<CardContent className="flex min-h-72 flex-col items-center justify-center text-center">
|
||||
<LockKeyhole className="size-8 text-muted-foreground" />
|
||||
<p className="mt-3 font-semibold">
|
||||
Assignment access required
|
||||
</p>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
You do not have permission to assign this ticket.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={backToTicket}
|
||||
>
|
||||
Back to ticket
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<AssignTicketForm
|
||||
ticketId={ticketId}
|
||||
videoId={videoId}
|
||||
selectedClassNames={selectedIssueTypes}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onAssigned={() => {
|
||||
setStartPoint(null);
|
||||
setEndPoint(null);
|
||||
}}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { BriefcaseBusiness, CalendarClock, ListChecks } from 'lucide-react';
|
||||
|
||||
import { PersonInfo } from '@/components/person-avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TicketActor, TicketAssignmentSummary } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { useTicketAssignmentsQuery } from '../../hooks/useTicketQueries';
|
||||
|
||||
interface TicketAssignmentsCardProps {
|
||||
ticketId: string;
|
||||
initialAssignments?: TicketAssignmentSummary[];
|
||||
selectedAssignmentId?: number;
|
||||
onAssignmentChange: (assignmentId: number) => void;
|
||||
}
|
||||
|
||||
function formatLabel(value: string) {
|
||||
return value
|
||||
.replaceAll('_', ' ')
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
function AssignmentClassBadge({ className }: { className: string }) {
|
||||
const visual = getDefectVisual(className);
|
||||
const IssueIcon = visual.icon;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-6 gap-1.5 px-2 font-normal"
|
||||
style={{
|
||||
borderColor: `${visual.boundingBoxColor}66`,
|
||||
backgroundColor: `${visual.boundingBoxColor}14`,
|
||||
color: visual.boundingBoxColor,
|
||||
}}
|
||||
>
|
||||
<IssueIcon />
|
||||
{formatLabel(className)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentProgress({
|
||||
assignment,
|
||||
}: {
|
||||
assignment: TicketAssignmentSummary;
|
||||
}) {
|
||||
const total = Math.max(assignment.item_count, 0);
|
||||
const approved = Math.max(assignment.approved_detections, 0);
|
||||
const progress = total > 0 ? Math.min((approved / total) * 100, 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex min-w-0 flex-col gap-1 text-xs sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<span className="font-medium text-foreground">Repair progress</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{approved} of {total} approved
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={assignment.is_complete ? 100 : progress} />
|
||||
<div className="flex min-w-0 flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<span className="whitespace-nowrap text-emerald-600">
|
||||
{assignment.approved_detections} approved
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-amber-600">
|
||||
{assignment.pending_detections} pending
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-muted-foreground">
|
||||
{assignment.not_submitted_detections} not submitted
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentLot({
|
||||
assignment,
|
||||
}: {
|
||||
assignment: TicketAssignmentSummary;
|
||||
}) {
|
||||
const criteria = assignment.criteria;
|
||||
const classNames = criteria?.class_names?.length
|
||||
? criteria.class_names
|
||||
: assignment.defect_class
|
||||
? [assignment.defect_class]
|
||||
: [];
|
||||
const worker: TicketActor = {
|
||||
user_id: assignment.assigned_to_user_id,
|
||||
name: assignment.assigned_to_name,
|
||||
email: assignment.assigned_to_email ?? null,
|
||||
avatar_url: assignment.assigned_to_avatar_url ?? null,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-4 rounded-lg border p-3 sm:p-4">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Lot {assignment.id}
|
||||
</p>
|
||||
<PersonInfo person={worker} />
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
|
||||
{classNames.map((className) => (
|
||||
<AssignmentClassBadge key={className} className={className} />
|
||||
))}
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ListChecks className="size-3.5" />
|
||||
{assignment.item_count}{' '}
|
||||
{assignment.item_count === 1 ? 'issue' : 'issues'}
|
||||
</span>
|
||||
{assignment.due_at ? (
|
||||
<span className="flex min-w-0 max-w-full items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<CalendarClock className="size-3.5" />
|
||||
Due {formatDate(assignment.due_at)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<AssignmentProgress assignment={assignment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketAssignmentsCard({
|
||||
ticketId,
|
||||
initialAssignments,
|
||||
selectedAssignmentId,
|
||||
onAssignmentChange,
|
||||
}: TicketAssignmentsCardProps) {
|
||||
const assignmentsQuery = useTicketAssignmentsQuery(
|
||||
ticketId,
|
||||
initialAssignments,
|
||||
);
|
||||
const assignments = assignmentsQuery.data?.items ?? [];
|
||||
const selectedAssignment =
|
||||
assignments.find((assignment) => assignment.id === selectedAssignmentId) ??
|
||||
assignments[0];
|
||||
const activeAssignmentId = selectedAssignment
|
||||
? String(selectedAssignment.id)
|
||||
: '';
|
||||
const resolvedAssignmentId = selectedAssignment?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
resolvedAssignmentId !== undefined &&
|
||||
resolvedAssignmentId !== selectedAssignmentId
|
||||
) {
|
||||
onAssignmentChange(resolvedAssignmentId);
|
||||
}
|
||||
}, [onAssignmentChange, resolvedAssignmentId, selectedAssignmentId]);
|
||||
|
||||
return (
|
||||
<Card className="min-w-0 max-w-full">
|
||||
<CardHeader className="min-w-0 px-3 sm:px-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BriefcaseBusiness className="size-5 text-primary" />
|
||||
Assignment lots
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Live ownership, criteria, and repair progress for this ticket.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{assignmentsQuery.data ? (
|
||||
<Badge variant="secondary">{assignmentsQuery.data.total}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="min-w-0 space-y-3 overflow-hidden px-3 sm:px-6">
|
||||
{assignmentsQuery.isLoading ? (
|
||||
Array.from({ length: 2 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-40 animate-pulse rounded-lg bg-muted"
|
||||
/>
|
||||
))
|
||||
) : assignmentsQuery.isError ? (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center">
|
||||
<p className="text-sm font-medium">
|
||||
Unable to load assignment lots.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => void assignmentsQuery.refetch()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : selectedAssignment ? (
|
||||
<div className="min-w-0 space-y-3">
|
||||
<nav
|
||||
aria-label="Assignment lots"
|
||||
className="w-full max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain touch-pan-x"
|
||||
>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-orientation="horizontal"
|
||||
className="flex w-max min-w-full border-b"
|
||||
>
|
||||
{assignments.map((assignment) => {
|
||||
const assignmentId = String(assignment.id);
|
||||
const isActive = assignmentId === activeAssignmentId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={assignment.id}
|
||||
id={`assignment-tab-${assignmentId}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`assignment-panel-${assignmentId}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
className={cn(
|
||||
'relative shrink-0 px-3 py-2.5 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||
isActive
|
||||
? 'text-foreground after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={() => onAssignmentChange(assignment.id)}
|
||||
>
|
||||
Lot {assignment.id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
id={`assignment-panel-${activeAssignmentId}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`assignment-tab-${activeAssignmentId}`}
|
||||
className="min-w-0"
|
||||
>
|
||||
<AssignmentLot assignment={selectedAssignment} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium">No assignments yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Assignment lots will appear here after work is allocated.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -13,15 +13,14 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
useDiscardDetectionMutation,
|
||||
useTicketClassDetectionsQuery,
|
||||
useTicketDetectionsQuery,
|
||||
} from '../../hooks/useTicketQueries';
|
||||
import { DetectionDiscardDialog } from './DetectionDiscardDialog';
|
||||
|
||||
interface TicketClassDetectionPreviewProps {
|
||||
ticketId: string;
|
||||
videoId?: string | null;
|
||||
defectClassName: string;
|
||||
displayName: string;
|
||||
assignmentId?: number;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
@@ -96,47 +95,45 @@ function TicketClassDetectionPreviewSkeleton() {
|
||||
export function TicketClassDetectionPreview({
|
||||
ticketId,
|
||||
videoId,
|
||||
defectClassName,
|
||||
displayName,
|
||||
assignmentId,
|
||||
totalCount,
|
||||
}: TicketClassDetectionPreviewProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
|
||||
const visual = getDefectVisual(defectClassName);
|
||||
const IssueIcon = visual.icon;
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentIndex(0);
|
||||
}, [defectClassName]);
|
||||
}, [assignmentId, videoId]);
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
class_name: defectClassName,
|
||||
skip: currentIndex,
|
||||
limit: 1,
|
||||
assignment_id: assignmentId,
|
||||
sort: 'timestamp_asc',
|
||||
}),
|
||||
[currentIndex, defectClassName],
|
||||
[assignmentId, currentIndex],
|
||||
);
|
||||
|
||||
const detectionsQuery = useTicketClassDetectionsQuery(
|
||||
const detectionsQuery = useTicketDetectionsQuery(
|
||||
videoId ?? undefined,
|
||||
queryParams,
|
||||
);
|
||||
const detectionResult = detectionsQuery.data;
|
||||
const activeDetection = detectionResult?.items[0];
|
||||
const detectionCount =
|
||||
detectionResult?.total ?? (assignmentId ? 0 : (totalCount ?? 0));
|
||||
const activeVisual = getDefectVisual(
|
||||
activeDetection?.detection.class_name ?? '',
|
||||
);
|
||||
const ActiveIssueIcon = activeVisual.icon;
|
||||
const activeDisplayName =
|
||||
activeDetection?.detection.display_name ?? 'Detection';
|
||||
const discardMutation = useDiscardDetectionMutation(
|
||||
ticketId,
|
||||
videoId ?? undefined,
|
||||
defectClassName,
|
||||
activeDetection?.detection.class_name,
|
||||
);
|
||||
const detectionResult = detectionsQuery.data;
|
||||
const activeDetection = detectionResult?.items[0];
|
||||
const detectionCount = detectionResult?.total ?? totalCount ?? 0;
|
||||
const mediaAspectRatio =
|
||||
detectionResult &&
|
||||
detectionResult.video_width > 0 &&
|
||||
detectionResult.video_height > 0
|
||||
? `${detectionResult.video_width} / ${detectionResult.video_height}`
|
||||
: '16 / 9';
|
||||
|
||||
useEffect(() => {
|
||||
if (detectionCount === 0 && currentIndex !== 0) {
|
||||
@@ -159,27 +156,22 @@ export function TicketClassDetectionPreview({
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-10 shrink-0 self-start items-center justify-center rounded-lg',
|
||||
visual.cardClassName,
|
||||
activeVisual.cardClassName,
|
||||
)}
|
||||
>
|
||||
<IssueIcon className={cn('size-5', visual.colorClassName)} />
|
||||
<ActiveIssueIcon
|
||||
className={cn('size-5', activeVisual.colorClassName)}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3>{displayName}</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'rounded-lg',
|
||||
visual.cardClassName,
|
||||
visual.colorClassName,
|
||||
)}
|
||||
>
|
||||
<h3>{activeDisplayName}</h3>
|
||||
<Badge variant="secondary" className="rounded-lg">
|
||||
{detectionCount} Detections
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Review detections in ascending timestamp order.
|
||||
Review all detections in ascending timestamp order.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,11 +241,13 @@ export function TicketClassDetectionPreview({
|
||||
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<p className="text-muted-foreground">Detection Image</p>
|
||||
<div className="overflow-hidden rounded-lg">
|
||||
<div className="w-full overflow-hidden rounded-lg">
|
||||
<AnnotatedDetectionImage
|
||||
detection={activeDetection}
|
||||
videoWidth={detectionResult.video_width}
|
||||
videoHeight={detectionResult.video_height}
|
||||
aspectRatio="16 / 9"
|
||||
enableLightbox
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -262,10 +256,10 @@ export function TicketClassDetectionPreview({
|
||||
className="min-w-0"
|
||||
latitude={activeDetection.location.latitude}
|
||||
longitude={activeDetection.location.longitude}
|
||||
label={`${displayName} - Frame ${activeDetection.frame.number}`}
|
||||
label={`${activeDetection.detection.display_name} - Frame ${activeDetection.frame.number}`}
|
||||
title="Location on Map"
|
||||
variant="plain"
|
||||
aspectRatio={mediaAspectRatio}
|
||||
aspectRatio="16 / 9"
|
||||
showCoordinates={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Component, ListChecks } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { DEFECT_CLASS_STATUS_CONFIG } from '@/constants/defectClassStatus';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TicketOverviewDetail } from '@/types';
|
||||
|
||||
import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
|
||||
@@ -17,148 +12,59 @@ import { TicketClassDetectionPreview } from './TicketClassDetectionPreview';
|
||||
interface TicketDefectClassTabsProps {
|
||||
ticketId: string;
|
||||
ticket?: TicketOverviewDetail;
|
||||
assignmentId?: number;
|
||||
}
|
||||
|
||||
export function TicketDefectClassTabs({
|
||||
ticketId,
|
||||
ticket,
|
||||
assignmentId,
|
||||
}: TicketDefectClassTabsProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const defectClassParam = searchParams.get('defect_class');
|
||||
const defectClassesQuery = useTicketDefectClassesQuery(ticketId);
|
||||
const defectClasses = useMemo(
|
||||
() => defectClassesQuery.data?.defect_classes ?? [],
|
||||
[defectClassesQuery.data?.defect_classes],
|
||||
);
|
||||
const [selectedClass, setSelectedClass] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const defectClasses = defectClassesQuery.data?.defect_classes ?? [];
|
||||
|
||||
if (!defectClasses.length) return;
|
||||
|
||||
const hasUrlClass = defectClasses.some(
|
||||
(item) => item.class_name === defectClassParam,
|
||||
);
|
||||
|
||||
if (hasUrlClass) {
|
||||
setSelectedClass(defectClassParam ?? undefined);
|
||||
return;
|
||||
}
|
||||
if (hasUrlClass) return;
|
||||
|
||||
const firstClass = defectClasses[0].class_name;
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.set('defect_class', firstClass);
|
||||
setSelectedClass(firstClass);
|
||||
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||
}, [defectClassParam, defectClasses, pathname, router, searchParams]);
|
||||
}, [
|
||||
defectClassParam,
|
||||
defectClassesQuery.data?.defect_classes,
|
||||
pathname,
|
||||
router,
|
||||
searchParams,
|
||||
]);
|
||||
|
||||
const handleClassChange = (className: string) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
nextParams.set('defect_class', className);
|
||||
setSelectedClass(className);
|
||||
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||
};
|
||||
const selectedIssue = ticket?.ai_result.detections_by_class.find(
|
||||
(item) => item.class_name === selectedClass,
|
||||
);
|
||||
const selectedClassItem = defectClasses.find(
|
||||
(item) => item.class_name === selectedClass,
|
||||
);
|
||||
const previewDisplayName =
|
||||
selectedIssue?.display_name ??
|
||||
selectedClassItem?.display_name ??
|
||||
selectedClass ??
|
||||
'Detection';
|
||||
const previewVideoId = ticket?.video_id ?? defectClassesQuery.data?.video_id;
|
||||
|
||||
if (defectClassesQuery.isLoading) {
|
||||
return (
|
||||
<Card className="w-full rounded-lg">
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<div className="h-5 w-24 animate-pulse rounded-lg bg-muted" />
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-11 w-full animate-pulse rounded-lg bg-muted"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (defectClassesQuery.isError || !defectClasses.length || !selectedClass) {
|
||||
return null;
|
||||
}
|
||||
if (!previewVideoId) return null;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={selectedClass}
|
||||
onValueChange={handleClassChange}
|
||||
orientation="vertical"
|
||||
className="w-full"
|
||||
>
|
||||
<Card className="w-full">
|
||||
<CardContent className="grid gap-0 p-0 lg:grid-cols-[minmax(220px,280px)_1fr] lg:divide-x lg:divide-border">
|
||||
<div className="space-y-4 px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListChecks className="size-5 shrink-0 text-primary" />
|
||||
<h2 className="text-base font-semibold">Detected Issues</h2>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="min-w-5 min-h-5 justify-center rounded-full"
|
||||
>
|
||||
{defectClasses.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<TabsList className="flex h-auto w-full flex-col items-stretch gap-2 bg-transparent p-0">
|
||||
{defectClasses.map((item) => {
|
||||
const statusConfig =
|
||||
DEFECT_CLASS_STATUS_CONFIG[item.assignment_status];
|
||||
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={item.class_name}
|
||||
value={item.class_name}
|
||||
className={cn(
|
||||
'group h-auto w-full flex-none items-center justify-between! gap-2 rounded-lg border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground shadow-none after:hidden',
|
||||
'data-[state=active]:bg-muted data-[state=active]:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate text-left font-medium">
|
||||
{item.display_name}
|
||||
</span>
|
||||
<Badge
|
||||
className={cn(
|
||||
'shrink-0 rounded-full',
|
||||
statusConfig.badgeClassName,
|
||||
)}
|
||||
>
|
||||
<Component className="size-3" />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 px-5">
|
||||
<TicketClassDetectionPreview
|
||||
ticketId={ticketId}
|
||||
videoId={previewVideoId}
|
||||
defectClassName={selectedClass}
|
||||
displayName={previewDisplayName}
|
||||
totalCount={selectedIssue?.count}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
<Card className="w-full">
|
||||
<CardContent className="p-5">
|
||||
<div className="min-w-0">
|
||||
<TicketClassDetectionPreview
|
||||
ticketId={ticketId}
|
||||
videoId={previewVideoId}
|
||||
assignmentId={assignmentId}
|
||||
totalCount={ticket?.ai_result.detection_count}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,19 @@ import { ArrowLeft } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { TicketOverviewDetail } from '@/types';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import type { TicketDetail, TicketOverviewDetail } from '@/types';
|
||||
|
||||
import { AssignTicketAction } from './actions/AssignTicketAction';
|
||||
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
|
||||
|
||||
export function TicketDetailHeader({
|
||||
ticket,
|
||||
classDetail,
|
||||
}: {
|
||||
ticket: TicketOverviewDetail;
|
||||
classDetail?: TicketDetail;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const ticketLabel = ticket.ticket_name || ticket.id;
|
||||
@@ -22,10 +29,15 @@ export function TicketDetailHeader({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/ticket')}
|
||||
>
|
||||
{classDetail?.assignment_status === 'under_review' ? (
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.REVIEW}>
|
||||
<OpenRepairReviewAction ticket={classDetail} />
|
||||
</PermissionGuard>
|
||||
) : null}
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||
<AssignTicketAction ticketId={ticket.id} />
|
||||
</PermissionGuard>
|
||||
<Button variant="secondary" onClick={() => router.push('/ticket')}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to List
|
||||
</Button>
|
||||
|
||||
@@ -2,34 +2,24 @@
|
||||
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import type { TicketDetail } from '@/types';
|
||||
import type { TicketAssignmentSummary, TicketDetail } from '@/types';
|
||||
|
||||
import { AssignedContractorAction } from './actions/AssignedContractorAction';
|
||||
import { AssignTicketAction } from './actions/AssignTicketAction';
|
||||
import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
||||
import { NoTicketAction } from './actions/NoTicketAction';
|
||||
import { RequestExtensionAction } from './actions/RequestExtensionAction';
|
||||
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
||||
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
||||
import { SubmittedRepairProofAction } from './actions/SubmittedRepairProofAction';
|
||||
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
||||
|
||||
interface TicketStatusActionsProps {
|
||||
ticket: TicketDetail;
|
||||
assignment?: TicketAssignmentSummary;
|
||||
}
|
||||
|
||||
function getTicketAction(ticket: TicketDetail) {
|
||||
if (ticket.assignment_status === 'unassigned') {
|
||||
return (
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||
fallback={<NoTicketAction />}
|
||||
>
|
||||
<AssignTicketAction ticket={ticket} />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
if (ticket.assignment_status === 'assigned') {
|
||||
function getTicketAction(
|
||||
ticket: TicketDetail,
|
||||
assignment?: TicketAssignmentSummary,
|
||||
) {
|
||||
if (assignment && !assignment.is_complete) {
|
||||
return (
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||
@@ -38,29 +28,46 @@ function getTicketAction(ticket: TicketDetail) {
|
||||
permissions={PERMISSIONS.TICKET.WORK}
|
||||
fallback={<NoTicketAction />}
|
||||
>
|
||||
<SubmitRepairAction ticket={ticket} />
|
||||
<RequestExtensionAction
|
||||
ticket={ticket}
|
||||
assignmentId={assignment.id}
|
||||
dueAt={assignment.due_at}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<AssignedContractorAction ticket={ticket} />
|
||||
<ReviewExtensionRequestAction ticket={ticket} />
|
||||
{ticket.assignment_status === 'assigned' ? (
|
||||
<AssignedContractorAction ticket={ticket} />
|
||||
) : null}
|
||||
<ReviewExtensionRequestAction
|
||||
ticket={ticket}
|
||||
assignmentId={assignment.id}
|
||||
/>
|
||||
</>
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
if (ticket.assignment_status === 'under_review') {
|
||||
if (ticket.assignment_status === 'unassigned') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ticket.assignment_status === 'assigned') {
|
||||
return (
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.REVIEW}
|
||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||
fallback={<NoTicketAction />}
|
||||
>
|
||||
<ReviewRepairAction ticket={ticket} />
|
||||
<AssignedContractorAction ticket={ticket} />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
if (ticket.assignment_status === 'under_review') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
ticket.assignment_status === 'approved' ||
|
||||
ticket.assignment_status === 'rejected'
|
||||
@@ -71,16 +78,11 @@ function getTicketAction(ticket: TicketDetail) {
|
||||
return <NoTicketAction />;
|
||||
}
|
||||
|
||||
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
|
||||
const action = getTicketAction(ticket);
|
||||
const submittedProof = ticket.repair?.submitted_at ? (
|
||||
<SubmittedRepairProofAction ticket={ticket} />
|
||||
) : null;
|
||||
export function TicketStatusActions({
|
||||
ticket,
|
||||
assignment,
|
||||
}: TicketStatusActionsProps) {
|
||||
const action = getTicketAction(ticket, assignment);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{submittedProof}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
return action ? <div className="space-y-3">{action}</div> : null;
|
||||
}
|
||||
|
||||
@@ -1,111 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Users, Mail, Phone, Send, ShieldCheck } from 'lucide-react';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
|
||||
import type { AssignableTicketUser, TicketDetail } from '@/types';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
const [selectedContractor, setSelectedContractor] =
|
||||
useState<AssignableTicketUser | null>(null);
|
||||
const [dueDate, setDueDate] = useState<Date>();
|
||||
const [assignNote, setAssignNote] = useState('');
|
||||
|
||||
const assignMutation = useAssignTicketMutation(ticket.id);
|
||||
const today = useMemo(() => {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}, []);
|
||||
const maxDueMonth = useMemo(
|
||||
() => new Date(today.getFullYear() + 20, 11),
|
||||
[today],
|
||||
);
|
||||
export function AssignTicketAction({ ticketId }: { ticketId: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={Users} title="Assign to Contractor">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Contractor *</Label>
|
||||
<AssignableWorkerSelect
|
||||
value={selectedUserId}
|
||||
onValueChange={(userId, user) => {
|
||||
setSelectedUserId(userId);
|
||||
setSelectedContractor(user);
|
||||
}}
|
||||
disabled={assignMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
{selectedContractor ? (
|
||||
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
|
||||
<p className="text-sm font-medium">Contractor Details</p>
|
||||
{selectedContractor.phone_number ? (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Phone className="size-4 shrink-0" />
|
||||
{selectedContractor.phone_number}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Mail className="size-4 shrink-0" />
|
||||
<span className="truncate">{selectedContractor.email}</span>
|
||||
</p>
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ShieldCheck className="size-4 shrink-0" />
|
||||
{selectedContractor.role.name}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="assign-due-date">
|
||||
Due Date <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<DatePickerSimple
|
||||
id="assign-due-date"
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
showLabel={false}
|
||||
placeholder="Select due date"
|
||||
disabled={assignMutation.isPending}
|
||||
className="w-full"
|
||||
startMonth={today}
|
||||
endMonth={maxDueMonth}
|
||||
disabledDates={{ before: today }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Assignment Note (Optional)</Label>
|
||||
<Textarea
|
||||
value={assignNote}
|
||||
onChange={(event) => setAssignNote(event.target.value)}
|
||||
placeholder="Add any instructions or notes for the contractor..."
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!selectedUserId || !dueDate || assignMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!selectedUserId || !dueDate) return;
|
||||
assignMutation.mutate({
|
||||
assigned_to_user_id: Number(selectedUserId),
|
||||
defect_class: ticket.defect_class,
|
||||
due_at: dueDate.toISOString(),
|
||||
note: assignNote || undefined,
|
||||
});
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Send />
|
||||
Assign Ticket
|
||||
</Button>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => router.push(ROUTES.TICKET_ASSIGN(ticketId))}
|
||||
>
|
||||
<Users />
|
||||
Assign Ticket
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { TicketDetail } from '@/types';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
export function OpenRepairReviewAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
router.push(ROUTES.TICKET_CLASS_REVIEW(ticket.id, ticket.defect_class))
|
||||
}
|
||||
>
|
||||
<ClipboardCheck />
|
||||
Review Repairs
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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,26 @@
|
||||
import { Clock3 } from 'lucide-react';
|
||||
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
|
||||
|
||||
export function RequestExtensionAction({
|
||||
ticket,
|
||||
assignmentId,
|
||||
dueAt,
|
||||
}: {
|
||||
ticket: TicketDetail;
|
||||
assignmentId: number;
|
||||
dueAt: string | null;
|
||||
}) {
|
||||
return (
|
||||
<TicketActionCard icon={Clock3} title="Request Extension">
|
||||
<TicketExtensionRequestPanel
|
||||
ticket={ticket}
|
||||
assignmentId={assignmentId}
|
||||
dueAt={dueAt}
|
||||
/>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -16,14 +16,15 @@ const MAX_REASON_LENGTH = 300;
|
||||
|
||||
interface RequestExtensionFormProps {
|
||||
ticket: TicketDetail;
|
||||
onCancel: () => void;
|
||||
assignmentId: number;
|
||||
currentDueAt: string | null;
|
||||
}
|
||||
|
||||
export function RequestExtensionForm({
|
||||
ticket,
|
||||
onCancel,
|
||||
assignmentId,
|
||||
currentDueAt,
|
||||
}: RequestExtensionFormProps) {
|
||||
const currentDueAt = ticket.worker?.due_at;
|
||||
const currentDueDate = useMemo(
|
||||
() => (currentDueAt ? new Date(currentDueAt) : undefined),
|
||||
[currentDueAt],
|
||||
@@ -40,7 +41,6 @@ export function RequestExtensionForm({
|
||||
);
|
||||
const [requestedDueDate, setRequestedDueDate] = useState<Date>();
|
||||
const [reason, setReason] = useState('');
|
||||
const assignmentId = ticket.worker?.assignment_id;
|
||||
const requestExtensionMutation = useRequestTicketExtensionMutation(
|
||||
ticket.id,
|
||||
assignmentId,
|
||||
@@ -52,10 +52,7 @@ export function RequestExtensionForm({
|
||||
(!currentDueDate || requestedDueDate.getTime() > currentDueDate.getTime()),
|
||||
);
|
||||
const canSubmit =
|
||||
Boolean(assignmentId) &&
|
||||
isRequestedDateValid &&
|
||||
Boolean(reason.trim()) &&
|
||||
!isPending;
|
||||
isRequestedDateValid && Boolean(reason.trim()) && !isPending;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!requestedDueDate || !canSubmit) return;
|
||||
@@ -66,7 +63,6 @@ export function RequestExtensionForm({
|
||||
});
|
||||
setRequestedDueDate(undefined);
|
||||
setReason('');
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -138,20 +134,15 @@ export function RequestExtensionForm({
|
||||
This request will be sent to your manager for approval.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
|
||||
{isPending ? 'Submitting...' : 'Submit Request'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
|
||||
{isPending ? 'Submitting...' : 'Submit Request'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,16 +77,17 @@ function Requester({ request }: { request: TicketExtensionRequest }) {
|
||||
|
||||
function ExtensionReviewForm({
|
||||
ticket,
|
||||
assignmentId,
|
||||
request,
|
||||
}: {
|
||||
ticket: TicketDetail;
|
||||
assignmentId: number;
|
||||
request: TicketExtensionRequest;
|
||||
}) {
|
||||
const [note, setNote] = useState('');
|
||||
const [selectedAction, setSelectedAction] = useState<
|
||||
'approve' | 'reject' | null
|
||||
>(null);
|
||||
const assignmentId = ticket.worker?.assignment_id as number;
|
||||
const reviewMutation = useReviewTicketExtensionMutation(
|
||||
ticket.id,
|
||||
assignmentId,
|
||||
@@ -197,16 +198,17 @@ function ExtensionReviewForm({
|
||||
|
||||
export function ReviewExtensionRequestAction({
|
||||
ticket,
|
||||
assignmentId,
|
||||
}: {
|
||||
ticket: TicketDetail;
|
||||
assignmentId: number;
|
||||
}) {
|
||||
const assignmentId = ticket.worker?.assignment_id;
|
||||
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
||||
ticket.id,
|
||||
assignmentId,
|
||||
);
|
||||
|
||||
if (!assignmentId || extensionRequestQuery.isError) {
|
||||
if (extensionRequestQuery.isError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -227,5 +229,11 @@ export function ReviewExtensionRequestAction({
|
||||
|
||||
if (!pendingRequest) return null;
|
||||
|
||||
return <ExtensionReviewForm ticket={ticket} request={pendingRequest} />;
|
||||
return (
|
||||
<ExtensionReviewForm
|
||||
ticket={ticket}
|
||||
assignmentId={assignmentId}
|
||||
request={pendingRequest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { RequestExtensionForm } from './RequestExtensionForm';
|
||||
|
||||
interface TicketExtensionRequestPanelProps {
|
||||
ticket: TicketDetail;
|
||||
onCancelRequest: () => void;
|
||||
}
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -192,22 +187,18 @@ function ExtensionRequestDetails({
|
||||
|
||||
export function TicketExtensionRequestPanel({
|
||||
ticket,
|
||||
onCancelRequest,
|
||||
}: TicketExtensionRequestPanelProps) {
|
||||
const assignmentId = ticket.worker?.assignment_id;
|
||||
assignmentId,
|
||||
dueAt,
|
||||
}: {
|
||||
ticket: TicketDetail;
|
||||
assignmentId: number;
|
||||
dueAt: string | null;
|
||||
}) {
|
||||
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
||||
ticket.id,
|
||||
assignmentId,
|
||||
);
|
||||
|
||||
if (!assignmentId) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
No active assignment is available for this ticket.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (extensionRequestQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
|
||||
@@ -241,7 +232,13 @@ export function TicketExtensionRequestPanel({
|
||||
: null;
|
||||
|
||||
if (!latestRequest) {
|
||||
return <RequestExtensionForm ticket={ticket} onCancel={onCancelRequest} />;
|
||||
return (
|
||||
<RequestExtensionForm
|
||||
ticket={ticket}
|
||||
assignmentId={assignmentId}
|
||||
currentDueAt={dueAt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ExtensionRequestDetails request={latestRequest} />;
|
||||
|
||||
@@ -11,11 +11,15 @@ import { protectedMediaService } from '@/services/api';
|
||||
|
||||
interface RepairProofGalleryProps {
|
||||
imageUrls: string[];
|
||||
maxVisibleImages?: number;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_IMAGES = 8;
|
||||
|
||||
export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
|
||||
export function RepairProofGallery({
|
||||
imageUrls,
|
||||
maxVisibleImages = MAX_VISIBLE_IMAGES,
|
||||
}: RepairProofGalleryProps) {
|
||||
const queries = useQueries({
|
||||
queries: imageUrls.map((url) => ({
|
||||
queryKey: ['protected-media', 'v2', url],
|
||||
@@ -44,7 +48,7 @@ export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
|
||||
if (queries.some((query) => query.isLoading)) {
|
||||
return (
|
||||
<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
|
||||
key={`${url}-${index}`}
|
||||
className="aspect-square rounded-lg"
|
||||
@@ -57,7 +61,7 @@ export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
|
||||
const images = objectUrls.flatMap((url, 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;
|
||||
|
||||
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"
|
||||
/>
|
||||
{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">
|
||||
+{overflowCount}
|
||||
<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} more
|
||||
</span>
|
||||
) : null}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
@@ -16,11 +17,13 @@ import {
|
||||
} from './components/TicketClassDetailSkeleton';
|
||||
|
||||
import { TicketHistoryCard } from './components/TicketHistoryCard';
|
||||
import { TicketAssignmentsCard } from './components/TicketAssignmentsCard';
|
||||
import { TicketOverviewCard } from './components/TicketOverviewCard';
|
||||
import { TicketStatusActions } from './components/TicketStatusActions';
|
||||
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
|
||||
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
|
||||
import {
|
||||
useTicketAssignmentsQuery,
|
||||
useTicketClassDetailQuery,
|
||||
useTicketOverviewQuery,
|
||||
} from '../hooks/useTicketQueries';
|
||||
@@ -30,11 +33,21 @@ export default function TicketDetailPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const { ticketId } = useParams() as { ticketId: string };
|
||||
const defectClass = searchParams.get('defect_class') ?? undefined;
|
||||
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>();
|
||||
|
||||
const overviewQuery = useTicketOverviewQuery(ticketId);
|
||||
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
|
||||
const overview = overviewQuery.data;
|
||||
const assignmentsQuery = useTicketAssignmentsQuery(
|
||||
ticketId,
|
||||
overview?.assignments,
|
||||
);
|
||||
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
|
||||
const classDetail = classDetailQuery.data;
|
||||
const assignments = assignmentsQuery.data?.items ?? [];
|
||||
const activeAssignmentId = selectedAssignmentId ?? assignments[0]?.id;
|
||||
const activeAssignment = assignments.find(
|
||||
(assignment) => assignment.id === activeAssignmentId,
|
||||
);
|
||||
const isClassDetailLoading = Boolean(
|
||||
defectClass && classDetailQuery.isLoading,
|
||||
);
|
||||
@@ -58,31 +71,48 @@ export default function TicketDetailPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative z-10 space-y-5">
|
||||
{overview ? (
|
||||
<TicketDetailHeader ticket={overview} />
|
||||
) : (
|
||||
<TicketOverviewHeaderSkeleton />
|
||||
)}
|
||||
<main className="relative z-10 min-w-0 max-w-full space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
|
||||
<div className="xl:shrink-0">
|
||||
{overview ? (
|
||||
<TicketDetailHeader ticket={overview} classDetail={classDetail} />
|
||||
) : (
|
||||
<TicketOverviewHeaderSkeleton />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]">
|
||||
<div className="space-y-5">
|
||||
<div className="grid min-w-0 items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)] xl:items-stretch xl:overflow-hidden">
|
||||
<div className="min-w-0 space-y-5 xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
{overview ? (
|
||||
<TicketOverviewCard ticket={overview} />
|
||||
) : (
|
||||
<TicketOverviewCardSkeleton />
|
||||
)}
|
||||
<TicketDefectClassTabs ticketId={ticketId} ticket={overview} />
|
||||
{overview ? (
|
||||
<TicketAssignmentsCard
|
||||
ticketId={ticketId}
|
||||
initialAssignments={overview.assignments}
|
||||
selectedAssignmentId={activeAssignmentId}
|
||||
onAssignmentChange={setSelectedAssignmentId}
|
||||
/>
|
||||
) : null}
|
||||
<TicketDefectClassTabs
|
||||
ticketId={ticketId}
|
||||
ticket={overview}
|
||||
assignmentId={activeAssignmentId}
|
||||
/>
|
||||
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
|
||||
{!isClassDetailLoading && classDetail ? (
|
||||
<TicketHistoryCard ticket={classDetail} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 self-start">
|
||||
<div className="min-w-0 space-y-3 self-start xl:min-h-0 xl:self-stretch xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
|
||||
{!isClassDetailLoading && classDetail ? (
|
||||
<TicketStatusActions ticket={classDetail} />
|
||||
<TicketStatusActions
|
||||
ticket={classDetail}
|
||||
assignment={activeAssignment}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,9 @@ export function useTicketDetailEvents(
|
||||
if (!ticketId) return;
|
||||
|
||||
queryClient.refetchQueries({ queryKey: ticketKeys.overview(ticketId) });
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
});
|
||||
if (defectClassRef.current) {
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClassRef.current),
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
'use client';
|
||||
|
||||
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 { detectionService, ticketService, videoService } from '@/services/api';
|
||||
import type {
|
||||
AssignTicketPayload,
|
||||
CloseTicketPayload,
|
||||
DetectionCoordinatesParams,
|
||||
DiscardDetectionPayload,
|
||||
RequestTicketExtensionPayload,
|
||||
ReviewDetectionRepairProofPayload,
|
||||
ReviewTicketExtensionPayload,
|
||||
ReviewRepairPayload,
|
||||
SubmitRepairPayload,
|
||||
SubmitRepairUploadPayload,
|
||||
TicketDetail,
|
||||
TicketAssignmentSummary,
|
||||
TicketListParams,
|
||||
DetectionProofStatus,
|
||||
VideoDetectionsParams,
|
||||
} from '@/types';
|
||||
|
||||
@@ -87,7 +93,26 @@ export function useTicketDefectClassesQuery(ticketId: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketClassDetectionsQuery(
|
||||
export function useTicketAssignmentsQuery(
|
||||
ticketId: string | undefined,
|
||||
initialAssignments?: TicketAssignmentSummary[],
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.assignments(ticketId ?? ''),
|
||||
queryFn: () => ticketService.getTicketAssignments(ticketId as string),
|
||||
enabled: Boolean(ticketId),
|
||||
initialData:
|
||||
initialAssignments !== undefined
|
||||
? {
|
||||
items: initialAssignments,
|
||||
total: initialAssignments.length,
|
||||
}
|
||||
: undefined,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketDetectionsQuery(
|
||||
videoId: string | undefined,
|
||||
params: VideoDetectionsParams,
|
||||
) {
|
||||
@@ -95,12 +120,14 @@ export function useTicketClassDetectionsQuery(
|
||||
() => ({
|
||||
skip: params.skip ?? 0,
|
||||
limit: params.limit ?? 1,
|
||||
assignment_id: params.assignment_id,
|
||||
class_name: params.class_name,
|
||||
min_confidence: params.min_confidence,
|
||||
sort: params.sort ?? 'timestamp_asc',
|
||||
search: params.search,
|
||||
}),
|
||||
[
|
||||
params.assignment_id,
|
||||
params.class_name,
|
||||
params.limit,
|
||||
params.min_confidence,
|
||||
@@ -114,14 +141,88 @@ export function useTicketClassDetectionsQuery(
|
||||
queryKey: ticketKeys.classDetections(videoId ?? '', queryParams),
|
||||
queryFn: () =>
|
||||
videoService.getVideoDetections(videoId as string, queryParams),
|
||||
enabled: Boolean(videoId && queryParams.class_name),
|
||||
enabled: Boolean(videoId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDetectionCoordinatesQuery(
|
||||
videoId: string | undefined,
|
||||
params: DetectionCoordinatesParams,
|
||||
) {
|
||||
const queryParams = useMemo<DetectionCoordinatesParams>(
|
||||
() => ({
|
||||
limit: params.limit ?? 50,
|
||||
class_name: params.class_name,
|
||||
assignment_status: params.assignment_status,
|
||||
search: params.search,
|
||||
}),
|
||||
[params.assignment_status, params.class_name, params.limit, params.search],
|
||||
);
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ticketKeys.detectionCoordinates(videoId ?? '', queryParams),
|
||||
queryFn: ({ pageParam }) =>
|
||||
detectionService.getCoordinates(videoId as string, {
|
||||
...queryParams,
|
||||
skip: pageParam,
|
||||
}),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (lastPage.items.length === 0) return undefined;
|
||||
|
||||
const loadedCount = allPages.reduce(
|
||||
(total, page) => total + page.items.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return loadedCount < lastPage.total ? loadedCount : undefined;
|
||||
},
|
||||
enabled: Boolean(videoId),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketClassDetectionsQuery(
|
||||
videoId: string | undefined,
|
||||
params: VideoDetectionsParams,
|
||||
) {
|
||||
return useTicketDetectionsQuery(videoId, params);
|
||||
}
|
||||
|
||||
export function useTicketClassReviewDetectionsQuery(
|
||||
videoId: string | undefined,
|
||||
defectClass: string | undefined,
|
||||
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,
|
||||
defectClass?: string,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -145,16 +246,71 @@ export function useDiscardDetectionMutation(
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.overview(ticketId),
|
||||
}),
|
||||
...(defectClass
|
||||
? [
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.defectClasses(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: () => toast.error('Failed to discard detection'),
|
||||
});
|
||||
}
|
||||
|
||||
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 discard detection'),
|
||||
onError: () => toast.error('Failed to review repair proof'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,60 +350,35 @@ function mergeTicketDetailResponse(
|
||||
};
|
||||
}
|
||||
|
||||
export function useAssignTicketMutation(ticketId: string) {
|
||||
export function useAssignTicketMutation(ticketId: string, videoId?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: AssignTicketPayload) =>
|
||||
ticketService.assignTicket(ticketId, payload),
|
||||
onSuccess: (ticket, payload) => {
|
||||
onSuccess: async () => {
|
||||
toast.success('Ticket assigned');
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.classDetail(ticketId, payload.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.overview(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
|
||||
...(videoId
|
||||
? [
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetectionLists(videoId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.detectionCoordinateLists(videoId),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
},
|
||||
onError: () => toast.error('Failed to assign ticket'),
|
||||
});
|
||||
}
|
||||
|
||||
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(
|
||||
ticketId: string,
|
||||
assignmentId: number | undefined,
|
||||
@@ -274,6 +405,9 @@ export function useRequestTicketExtensionMutation(
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
onError: () => toast.error('Failed to request an extension'),
|
||||
@@ -301,31 +435,14 @@ export function useReviewTicketExtensionMutation(
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
onError: () => toast.error('Failed to review extension request'),
|
||||
});
|
||||
}
|
||||
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) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { TicketListParams, VideoDetectionsParams } from '@/types';
|
||||
import type {
|
||||
DetectionCoordinatesParams,
|
||||
TicketListParams,
|
||||
VideoDetectionsParams,
|
||||
} from '@/types';
|
||||
|
||||
export const ticketKeys = {
|
||||
all: ['tickets'] as const,
|
||||
@@ -13,8 +17,30 @@ export const ticketKeys = {
|
||||
[...ticketKeys.details(), 'video', videoId, 'detections'] as const,
|
||||
classDetections: (videoId: string, params: VideoDetectionsParams) =>
|
||||
[...ticketKeys.classDetectionLists(videoId), params] as const,
|
||||
detectionCoordinateLists: (videoId: string) =>
|
||||
[
|
||||
...ticketKeys.details(),
|
||||
'video',
|
||||
videoId,
|
||||
'detection-coordinates',
|
||||
] as const,
|
||||
detectionCoordinates: (videoId: string, params: DetectionCoordinatesParams) =>
|
||||
[...ticketKeys.detectionCoordinateLists(videoId), params] as const,
|
||||
classReviewDetections: (
|
||||
videoId: string,
|
||||
defectClass: string,
|
||||
proofStatus?: string,
|
||||
) =>
|
||||
[
|
||||
...ticketKeys.classDetectionLists(videoId),
|
||||
'class-review',
|
||||
defectClass,
|
||||
proofStatus ?? 'all',
|
||||
] as const,
|
||||
defectClasses: (ticketId: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
|
||||
assignments: (ticketId: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'assignments'] as const,
|
||||
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
|
||||
[
|
||||
...ticketKeys.details(),
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
MoreVertical,
|
||||
PlayCircle,
|
||||
RotateCcw,
|
||||
Ticket,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { UploadListItem } from '@/types';
|
||||
|
||||
interface UploadActionsMenuProps {
|
||||
upload: UploadListItem;
|
||||
onViewVideo: (upload: UploadListItem) => void;
|
||||
}
|
||||
|
||||
export function UploadActionsMenu({
|
||||
upload,
|
||||
onViewVideo,
|
||||
}: UploadActionsMenuProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
<span className="sr-only">Open actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{upload.raw_video_url ? (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onViewVideo(upload);
|
||||
}}
|
||||
>
|
||||
<PlayCircle className="size-4" />
|
||||
View Video
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem>
|
||||
<Eye /> View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<RotateCcw /> Retry Analysis
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!upload.ticket_id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (upload.ticket_id) {
|
||||
router.push(`/ticket/${upload.ticket_id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ticket /> Open Ticket
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2 /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
143
src/app/(modules)/upload/components/UploadDetailsDialog.tsx
Normal file
143
src/app/(modules)/upload/components/UploadDetailsDialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ExternalLink, Ticket, UserRound } from 'lucide-react';
|
||||
|
||||
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import type { UploadListItem } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { UploadResultCell } from './UploadResultCell';
|
||||
import { UploadStatusBadge } from './UploadStatusBadge';
|
||||
|
||||
interface UploadDetailsDialogProps {
|
||||
upload: UploadListItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function UploadDetailsDialog({
|
||||
upload,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: UploadDetailsDialogProps) {
|
||||
const { hasPermission } = usePermissions();
|
||||
const canOpenTicket = hasPermission(PERMISSIONS.TICKET.READ);
|
||||
const details = [
|
||||
{ label: 'Project', value: upload?.project },
|
||||
{ label: 'Package', value: upload?.package },
|
||||
{ label: 'Segment', value: upload?.segment },
|
||||
{ label: 'Chainage', value: upload?.chainage },
|
||||
{ label: 'Uploaded', value: formatDate(upload?.uploaded_at) },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-6xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload Details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Preview the inspection video and review its processing details.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(18rem,0.8fr)]">
|
||||
<section className="min-w-0 space-y-2">
|
||||
<p className="text-sm font-medium">Video Preview</p>
|
||||
<ProtectedVideoPlayer
|
||||
url={open ? upload?.raw_video_url : undefined}
|
||||
label={upload?.video_name}
|
||||
className="rounded-lg"
|
||||
unavailableTitle="Video preview unavailable"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside className="min-w-0 space-y-4 rounded-lg border bg-muted/15 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Status</p>
|
||||
<div className="mt-1.5">
|
||||
{upload ? (
|
||||
<UploadStatusBadge
|
||||
status={upload.status}
|
||||
progress={upload.progress}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-muted-foreground">Detections</p>
|
||||
<Badge variant="secondary" className="mt-1.5 tabular-nums">
|
||||
{upload?.total_detections ?? 0}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="mb-2 text-xs text-muted-foreground">Result</p>
|
||||
{upload ? <UploadResultCell item={upload} /> : null}
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 border-t pt-4">
|
||||
{details.map((detail) => (
|
||||
<div
|
||||
key={detail.label}
|
||||
className={
|
||||
detail.label === 'Uploaded' ? 'col-span-2' : undefined
|
||||
}
|
||||
>
|
||||
<dt className="text-xs text-muted-foreground">
|
||||
{detail.label}
|
||||
</dt>
|
||||
<dd className="mt-1 truncate text-sm font-medium">
|
||||
{detail.value || '—'}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="flex min-w-0 items-start gap-2 border-t pt-4">
|
||||
<UserRound className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{upload?.uploaded_by.name || 'Unknown uploader'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{upload?.uploaded_by.email || 'No email available'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter showCloseButton>
|
||||
{upload?.ticket_id && canOpenTicket ? (
|
||||
<Button asChild>
|
||||
<Link href={ROUTES.TICKET_DETAIL(upload.ticket_id)}>
|
||||
<Ticket />
|
||||
Open Ticket
|
||||
<ExternalLink />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { Badge } from '@/components/ui/badge';
|
||||
import type { UploadListItem } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { UploadActionsMenu } from './UploadActionsMenu';
|
||||
import { UploadResultCell } from './UploadResultCell';
|
||||
import { UploadStatusBadge } from './UploadStatusBadge';
|
||||
|
||||
@@ -22,9 +21,7 @@ function resolveThumbnailUrl(value: string | null) {
|
||||
return baseUrl ? `${baseUrl}${path}` : path;
|
||||
}
|
||||
|
||||
export function useUploadListColumns(
|
||||
onViewVideo: (upload: UploadListItem) => void,
|
||||
): ColumnDef<UploadListItem>[] {
|
||||
export function useUploadListColumns(): ColumnDef<UploadListItem>[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -133,24 +130,7 @@ export function useUploadListColumns(
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => <UploadResultCell item={row.original} />,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 64,
|
||||
minSize: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center">
|
||||
<UploadActionsMenu
|
||||
upload={row.original}
|
||||
onViewVideo={onViewVideo}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onViewVideo],
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface UploadTableProps {
|
||||
skip: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
onUploadClick: (upload: UploadListItem) => void;
|
||||
onPageChange: (skip: number) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
}
|
||||
@@ -23,6 +24,7 @@ export function UploadTable({
|
||||
skip,
|
||||
limit,
|
||||
total,
|
||||
onUploadClick,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}: UploadTableProps) {
|
||||
@@ -33,6 +35,7 @@ export function UploadTable({
|
||||
isLoading={isLoading}
|
||||
emptyTitle="No uploads found"
|
||||
emptyDescription="Upload your first road inspection video."
|
||||
onRowClick={onUploadClick}
|
||||
pagination={{
|
||||
skip,
|
||||
limit,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { UploadListItem } from '@/types';
|
||||
|
||||
interface VideoPreviewDialogProps {
|
||||
upload: UploadListItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function VideoPreviewDialog({
|
||||
upload,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: VideoPreviewDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Video Preview</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<ProtectedVideoPlayer
|
||||
url={open ? upload?.raw_video_url : undefined}
|
||||
className="rounded-lg"
|
||||
unavailableTitle="Video preview unavailable"
|
||||
/>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type { UploadListItem } from '@/types';
|
||||
import { CreateUploadDialog } from './components/CreateUploadDialog';
|
||||
import { useUploadListColumns } from './components/UploadListColumns';
|
||||
import { UploadTable } from './components/UploadTable';
|
||||
import { VideoPreviewDialog } from './components/VideoPreviewDialog';
|
||||
import { UploadDetailsDialog } from './components/UploadDetailsDialog';
|
||||
import { useUploadFilters } from './hooks/useUploadFilters';
|
||||
import { useUploadListEvents } from './hooks/useUploadListEvents';
|
||||
import { useUploadsQuery } from './hooks/useUploadQueries';
|
||||
@@ -20,13 +20,15 @@ export default function UploadPage() {
|
||||
const uploadsQuery = useUploadsQuery({ skip, limit });
|
||||
useUploadListEvents();
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [videoUpload, setVideoUpload] = useState<UploadListItem | null>(null);
|
||||
const [selectedUpload, setSelectedUpload] = useState<UploadListItem | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const uploads = uploadsQuery.data?.items ?? [];
|
||||
const total = uploadsQuery.data?.total ?? 0;
|
||||
const isLoading = uploadsQuery.isLoading;
|
||||
|
||||
const columns = useUploadListColumns(setVideoUpload);
|
||||
const columns = useUploadListColumns();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -50,17 +52,18 @@ export default function UploadPage() {
|
||||
skip={skip}
|
||||
limit={limit}
|
||||
total={total}
|
||||
onUploadClick={setSelectedUpload}
|
||||
onPageChange={setSkip}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} />
|
||||
<VideoPreviewDialog
|
||||
upload={videoUpload}
|
||||
open={Boolean(videoUpload)}
|
||||
<UploadDetailsDialog
|
||||
upload={selectedUpload}
|
||||
open={Boolean(selectedUpload)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setVideoUpload(null);
|
||||
if (!open) setSelectedUpload(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'size-8 rounded-lg border-2 p-0.5',
|
||||
md: 'size-12 rounded-lg border-2 p-1.5',
|
||||
lg: 'size-28 rounded-lg border-[5px] p-3',
|
||||
} as const;
|
||||
|
||||
const variantClasses = {
|
||||
default: 'border-white bg-primary shadow-md',
|
||||
solid: 'border-white bg-primary',
|
||||
glass: 'border-primary-foreground bg-primary/85 shadow-md',
|
||||
sidebar: 'border-0 bg-primary',
|
||||
} as const;
|
||||
|
||||
type LogoWrapperProps = {
|
||||
alt?: string;
|
||||
className?: string;
|
||||
imageClassName?: string;
|
||||
priority?: boolean;
|
||||
size?: keyof typeof sizeClasses;
|
||||
variant?: keyof typeof variantClasses;
|
||||
};
|
||||
|
||||
export function LogoWrapper({
|
||||
alt = 'RoadMonitor logo',
|
||||
className,
|
||||
imageClassName,
|
||||
priority = false,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
}: LogoWrapperProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center',
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt={alt}
|
||||
width={112}
|
||||
height={112}
|
||||
className={cn(
|
||||
'size-full object-contain brightness-0 invert',
|
||||
imageClassName,
|
||||
)}
|
||||
priority={priority}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
import * as React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { LogoWrapper } from '@/components/LogoWrapper';
|
||||
import Image from 'next/image';
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import {
|
||||
filterMenuItems,
|
||||
@@ -55,7 +55,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
[authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') ||
|
||||
'Admin',
|
||||
email: authUser?.email || '',
|
||||
avatar: authUser?.profile_photo_url || '/avatars/profile.jpg',
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -97,10 +96,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
function SidebarBrandControl({ isMobile }: { isMobile: boolean }) {
|
||||
return (
|
||||
<div className="group/sidebar-brand relative flex size-8 shrink-0 items-center justify-center">
|
||||
<LogoWrapper
|
||||
size="sm"
|
||||
variant="sidebar"
|
||||
className="transition-opacity duration-150 group-data-[collapsible=icon]:group-hover/sidebar-brand:opacity-0 group-data-[collapsible=icon]:group-focus-within/sidebar-brand:opacity-0"
|
||||
<Image
|
||||
src="/color_logo.svg"
|
||||
alt="RoadMonitor logo"
|
||||
width={81}
|
||||
height={58}
|
||||
className="h-auto w-10 transition-opacity duration-150 group-data-[collapsible=icon]:group-hover/sidebar-brand:opacity-0 group-data-[collapsible=icon]:group-focus-within/sidebar-brand:opacity-0"
|
||||
priority
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LogoWrapper } from '@/components/LogoWrapper';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image from 'next/image';
|
||||
|
||||
const BOKEH_ORBS = [
|
||||
'top-[20%] left-[10%] size-[min(18vw,140px)] opacity-[0.14]',
|
||||
@@ -82,24 +82,21 @@ export function AuthBackground({ className }: AuthBackgroundProps) {
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="absolute inset-x-10 top-1/2 -translate-y-1/2 text-center text-primary-foreground">
|
||||
<div className="mb-9 inline-flex items-center gap-5 text-left">
|
||||
<LogoWrapper
|
||||
alt=""
|
||||
className="size-28 rounded-lg border-[5px] p-4"
|
||||
priority
|
||||
variant="glass"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-5xl font-semibold leading-none tracking-normal">
|
||||
Road
|
||||
</span>
|
||||
<span className="block text-5xl font-semibold leading-none tracking-normal">
|
||||
Monitor
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="mx-auto max-w-md text-base leading-7 text-primary-foreground/82">
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt=""
|
||||
width={81}
|
||||
height={58}
|
||||
className="absolute left-10 top-12 h-auto w-16 object-contain"
|
||||
priority
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-10 left-10 right-10 text-left text-primary-foreground">
|
||||
<p className="text-3xl leading-none tracking-tight">
|
||||
<span className="font-semibold">Road</span>
|
||||
<span className="font-normal">Monitor</span>
|
||||
</p>
|
||||
<p className="mt-1 max-w-sm text-xs leading-4 text-primary-foreground/90">
|
||||
Map, monitor, and repair road issues with data-driven surface
|
||||
intelligence.
|
||||
</p>
|
||||
@@ -107,4 +104,3 @@ export function AuthBackground({ className }: AuthBackgroundProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ interface MultiSelectPopoverProps {
|
||||
onValuesChange: (values: string[]) => void;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
emptySelectionLabel?: string;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ export function MultiSelectPopover({
|
||||
onValuesChange,
|
||||
searchPlaceholder = 'Search',
|
||||
emptyMessage = 'No options found.',
|
||||
emptySelectionLabel,
|
||||
align = 'end',
|
||||
}: MultiSelectPopoverProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -96,7 +98,11 @@ export function MultiSelectPopover({
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
<Badge variant="secondary">{values.length}</Badge>
|
||||
<Badge variant="secondary">
|
||||
{values.length === 0 && emptySelectionLabel
|
||||
? emptySelectionLabel
|
||||
: values.length}
|
||||
</Badge>
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -39,7 +40,6 @@ export function NavUser({
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
@@ -76,9 +76,10 @@ export function NavUser({
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>
|
||||
<UserRound className="size-4" aria-hidden="true" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{user.name}</span>
|
||||
@@ -95,9 +96,10 @@ export function NavUser({
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>
|
||||
<UserRound className="size-4" aria-hidden="true" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{user.name}</span>
|
||||
|
||||
@@ -12,7 +12,7 @@ function Card({
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-lg bg-card py-(--card-spacing) text-sm text-card-foreground shadow-xs ring-1 ring-foreground/10 [--card-spacing:--spacing(6)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-lg *:[img:last-child]:rounded-lg",
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-lg bg-card py-(--card-spacing) text-sm text-card-foreground shadow-xs ring-1 ring-inset ring-foreground/10 [--card-spacing:--spacing(6)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-lg *:[img:last-child]:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, ImageIcon, Loader2 } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
|
||||
import type { BoundingBoxOverlayItem, DetectionResultItem } from '@/types';
|
||||
|
||||
@@ -12,13 +14,18 @@ interface AnnotatedDetectionImageProps {
|
||||
detection?: DetectionResultItem;
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
aspectRatio?: string;
|
||||
enableLightbox?: boolean;
|
||||
}
|
||||
|
||||
export default function AnnotatedDetectionImage({
|
||||
detection,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
aspectRatio: aspectRatioOverride,
|
||||
enableLightbox = false,
|
||||
}: AnnotatedDetectionImageProps) {
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(
|
||||
detection?.detection.context_image_url,
|
||||
);
|
||||
@@ -35,9 +42,65 @@ export default function AnnotatedDetectionImage({
|
||||
: [];
|
||||
|
||||
const aspectRatio =
|
||||
videoWidth > 0 && videoHeight > 0
|
||||
aspectRatioOverride ??
|
||||
(videoWidth > 0 && videoHeight > 0
|
||||
? `${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 (
|
||||
<div
|
||||
@@ -60,20 +123,7 @@ export default function AnnotatedDetectionImage({
|
||||
<p>Failed to load the detection image.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Image
|
||||
src={objectUrl}
|
||||
alt={`${detection.detection.display_name} detection`}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-contain"
|
||||
/>
|
||||
<BoundingBoxOverlay
|
||||
detections={overlayDetections}
|
||||
videoWidth={videoWidth}
|
||||
videoHeight={videoHeight}
|
||||
/>
|
||||
</>
|
||||
annotatedMedia
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -57,6 +57,7 @@ export const API_ROUTES = {
|
||||
ANNOTATION_FRAMES: (id: string) =>
|
||||
`/biz/api/v1/results/${id}/annotation-frames`,
|
||||
DETECTIONS: (id: string) => `/biz/api/v1/results/${id}/detections`,
|
||||
COORDINATES: (id: string) => `/biz/api/v1/results/${id}/coordinates`,
|
||||
},
|
||||
DETECTIONS: {
|
||||
DISCARD: (id: number) => `/biz/api/v1/detections/${id}/discard`,
|
||||
@@ -67,17 +68,15 @@ export const API_ROUTES = {
|
||||
CLASS_DETAIL: (id: string, defectClass: string) =>
|
||||
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
|
||||
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,
|
||||
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,
|
||||
SUBMIT_REPAIR: (id: string) => `/biz/api/v1/tickets/${id}/submit-repair`,
|
||||
SUBMIT_REPAIR_UPLOAD: (id: string) =>
|
||||
`/biz/api/v1/tickets/${id}/repair-proof`,
|
||||
ASSIGNMENTS: (id: string) => `/biz/api/v1/tickets/${id}/assignments`,
|
||||
REQUEST_EXTENSION: (ticketId: string, assignmentId: number) =>
|
||||
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-requests`,
|
||||
EXTENSION_REQUESTS: (ticketId: string) =>
|
||||
`/biz/api/v1/tickets/${ticketId}/extension-requests`,
|
||||
REVIEW_EXTENSION_REQUEST: (ticketId: string, assignmentId: number) =>
|
||||
`/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`,
|
||||
DETAIL_EVENTS_TOKEN: (id: string) =>
|
||||
`/biz/api/v1/tickets/${id}/events/token`,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export const ENV_CONSTANT = {
|
||||
BASE_API_URL: process.env.NEXT_PUBLIC_API_URL,
|
||||
BASE_API_URL: '/',
|
||||
};
|
||||
|
||||
@@ -14,9 +14,6 @@ export const authService = {
|
||||
const response = await axiosAuth.post<AuthResponseData>(
|
||||
API_ROUTES.AUTH.LOGIN,
|
||||
payload,
|
||||
{
|
||||
withCredentials: true,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@@ -24,14 +21,11 @@ export const authService = {
|
||||
const response = await axiosAuth.post<AuthResponseData>(
|
||||
API_ROUTES.AUTH.REFRESH,
|
||||
{},
|
||||
{
|
||||
withCredentials: true,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
logout: async (): Promise<void> => {
|
||||
await axiosAuth.post(API_ROUTES.AUTH.LOGOUT, {}, { withCredentials: true });
|
||||
await axiosAuth.post(API_ROUTES.AUTH.LOGOUT, {});
|
||||
},
|
||||
me: async (): Promise<MeResponse> => {
|
||||
const response = await axiosClient.get<MeResponse>(API_ROUTES.AUTH.ME);
|
||||
|
||||
@@ -1,12 +1,46 @@
|
||||
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||
import type {
|
||||
DetectionCoordinatesParams,
|
||||
DetectionCoordinatesResponse,
|
||||
DiscardDetectionPayload,
|
||||
DiscardDetectionResponse,
|
||||
ReviewDetectionRepairProofPayload,
|
||||
ReviewDetectionRepairProofResponse,
|
||||
} from '@/types';
|
||||
|
||||
import axiosClient from '../axios/axios';
|
||||
|
||||
export const detectionService = {
|
||||
getCoordinates: async (
|
||||
videoId: string,
|
||||
params?: DetectionCoordinatesParams,
|
||||
): Promise<DetectionCoordinatesResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
params?.class_name?.forEach((className) => {
|
||||
searchParams.append('class_name', className);
|
||||
});
|
||||
if (params?.skip !== undefined) {
|
||||
searchParams.set('skip', String(params.skip));
|
||||
}
|
||||
if (params?.limit !== undefined) {
|
||||
searchParams.set('limit', String(params.limit));
|
||||
}
|
||||
if (params?.assignment_status) {
|
||||
searchParams.set('assignment_status', params.assignment_status);
|
||||
}
|
||||
if (params?.search) {
|
||||
searchParams.set('search', params.search);
|
||||
}
|
||||
|
||||
const response = await axiosClient.get<DetectionCoordinatesResponse>(
|
||||
API_ROUTES.VIDEOS.COORDINATES(videoId),
|
||||
{ params: searchParams },
|
||||
);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
discardDetection: async (
|
||||
detectionId: number,
|
||||
payload: DiscardDetectionPayload,
|
||||
@@ -18,4 +52,17 @@ export const detectionService = {
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
reviewRepairProof: async (
|
||||
ticketId: string,
|
||||
detectionId: number,
|
||||
payload: ReviewDetectionRepairProofPayload,
|
||||
): Promise<ReviewDetectionRepairProofResponse> => {
|
||||
const response = await axiosClient.post<ReviewDetectionRepairProofResponse>(
|
||||
API_ROUTES.TICKETS.REVIEW_DETECTION_REPAIR_PROOF(ticketId, detectionId),
|
||||
payload,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,11 +7,10 @@ import type {
|
||||
CloseTicketPayload,
|
||||
RequestTicketExtensionPayload,
|
||||
ReviewTicketExtensionPayload,
|
||||
ReviewRepairPayload,
|
||||
SseTokenResponse,
|
||||
SubmitRepairPayload,
|
||||
SubmitRepairUploadPayload,
|
||||
TicketDetail,
|
||||
TicketAssignmentSummary,
|
||||
TicketAssignmentsResponse,
|
||||
TicketExtensionRequestsResponse,
|
||||
TicketOverviewDetail,
|
||||
TicketDefectClassesResponse,
|
||||
@@ -19,6 +18,14 @@ import type {
|
||||
TicketListResponse,
|
||||
} from '@/types';
|
||||
|
||||
type TicketAssignmentsApiResponse =
|
||||
| TicketAssignmentSummary[]
|
||||
| TicketAssignmentsResponse
|
||||
| {
|
||||
assignments: TicketAssignmentSummary[];
|
||||
total?: number;
|
||||
};
|
||||
|
||||
export const ticketService = {
|
||||
getTickets: async (
|
||||
params?: TicketListParams,
|
||||
@@ -83,47 +90,34 @@ export const ticketService = {
|
||||
assignTicket: async (
|
||||
ticketId: string,
|
||||
payload: AssignTicketPayload,
|
||||
): Promise<TicketDetail> => {
|
||||
const response = await axiosClient.post<TicketDetail>(
|
||||
API_ROUTES.TICKETS.ASSIGN(ticketId),
|
||||
): Promise<TicketAssignmentSummary> => {
|
||||
const response = await axiosClient.post<TicketAssignmentSummary>(
|
||||
API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
submitRepair: async (
|
||||
getTicketAssignments: async (
|
||||
ticketId: string,
|
||||
payload: SubmitRepairPayload,
|
||||
): Promise<TicketDetail> => {
|
||||
const response = await axiosClient.post<TicketDetail>(
|
||||
API_ROUTES.TICKETS.SUBMIT_REPAIR(ticketId),
|
||||
payload,
|
||||
): Promise<TicketAssignmentsResponse> => {
|
||||
const response = await axiosClient.get<TicketAssignmentsApiResponse>(
|
||||
API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
const data = 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);
|
||||
});
|
||||
if (Array.isArray(data)) {
|
||||
return { items: data, total: data.length };
|
||||
}
|
||||
|
||||
const response = await axiosClient.post<TicketDetail>(
|
||||
API_ROUTES.TICKETS.SUBMIT_REPAIR_UPLOAD(ticketId),
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
if ('assignments' in data) {
|
||||
return {
|
||||
items: data.assignments,
|
||||
total: data.total ?? data.assignments.length,
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
requestTicketExtension: async (
|
||||
@@ -162,17 +156,6 @@ export const ticketService = {
|
||||
);
|
||||
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 (
|
||||
ticketId: string,
|
||||
payload: CloseTicketPayload,
|
||||
|
||||
@@ -73,11 +73,16 @@ export const videoService = {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 1,
|
||||
assignment_id: params?.assignment_id,
|
||||
class_name: params?.class_name,
|
||||
proof_status: params?.proof_status,
|
||||
min_confidence: params?.min_confidence,
|
||||
sort: params?.sort ?? 'timestamp_asc',
|
||||
search: params?.search,
|
||||
},
|
||||
paramsSerializer: {
|
||||
indexes: null,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
|
||||
@@ -7,6 +7,7 @@ const BASE_URL = ENV_CONSTANT.BASE_API_URL;
|
||||
|
||||
const axiosClient = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -14,6 +15,7 @@ const axiosClient = axios.create({
|
||||
|
||||
export const axiosAuth = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -30,13 +32,7 @@ const isInvalidSessionResponse = (error: unknown) => {
|
||||
const refreshAccessToken = () => {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = axiosAuth
|
||||
.post(
|
||||
'api/auth/refresh',
|
||||
{},
|
||||
{
|
||||
withCredentials: true,
|
||||
},
|
||||
)
|
||||
.post('api/auth/refresh', {})
|
||||
.then((response) => {
|
||||
const accessToken = response.data?.access_token;
|
||||
|
||||
|
||||
@@ -5,6 +5,38 @@ export type DetectionClassCount = {
|
||||
unique_count: number;
|
||||
};
|
||||
|
||||
export type DetectionCoordinateClass = {
|
||||
class_name: string;
|
||||
display_name: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type DetectionCoordinateItem = {
|
||||
id: number;
|
||||
class_name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
confidence: number;
|
||||
};
|
||||
|
||||
export type DetectionAssignmentStatus = 'assigned' | 'unassigned';
|
||||
|
||||
export type DetectionCoordinatesParams = {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
class_name?: string[];
|
||||
assignment_status?: DetectionAssignmentStatus;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type DetectionCoordinatesResponse = {
|
||||
total: number;
|
||||
limit: number;
|
||||
truncated: boolean;
|
||||
classes: DetectionCoordinateClass[];
|
||||
items: DetectionCoordinateItem[];
|
||||
};
|
||||
|
||||
export type DetectionBoundingBox = {
|
||||
x1: number;
|
||||
y1: number;
|
||||
@@ -22,6 +54,25 @@ export type BoundingBoxOverlayItem = {
|
||||
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 = {
|
||||
id: string;
|
||||
detection: {
|
||||
@@ -42,6 +93,7 @@ export type DetectionResultItem = {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
};
|
||||
repair_proof: DetectionRepairProof | null;
|
||||
};
|
||||
|
||||
export type DetectionResultLog = DetectionResultItem & {
|
||||
@@ -58,12 +110,21 @@ export type DetectionResultLog = DetectionResultItem & {
|
||||
export type VideoDetectionsParams = {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
assignment_id?: number;
|
||||
class_name?: string | string[];
|
||||
proof_status?: DetectionProofStatus;
|
||||
min_confidence?: number;
|
||||
sort?: string;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type DetectionProofStatus =
|
||||
| 'not_submitted'
|
||||
| 'submitted'
|
||||
| 'pending'
|
||||
| 'approved'
|
||||
| 'rejected';
|
||||
|
||||
export type VideoDetectionsResponse = {
|
||||
video_id: string;
|
||||
video_width: number;
|
||||
|
||||
@@ -22,23 +22,21 @@ export interface AssignableTicketUsersResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TicketAssignmentCriteria {
|
||||
class_names?: string[] | null;
|
||||
start_lat?: number | null;
|
||||
start_lng?: number | null;
|
||||
end_lat?: number | null;
|
||||
end_lng?: number | null;
|
||||
min_confidence?: number | null;
|
||||
}
|
||||
|
||||
export interface AssignTicketPayload {
|
||||
assigned_to_user_id: number;
|
||||
defect_class: string;
|
||||
assigned_to_email: string;
|
||||
due_at: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface SubmitRepairPayload {
|
||||
notes: string;
|
||||
proof_path: string;
|
||||
}
|
||||
|
||||
export interface SubmitRepairUploadPayload {
|
||||
defect_class: string;
|
||||
notes: string;
|
||||
video: File;
|
||||
images: File[];
|
||||
criteria?: TicketAssignmentCriteria;
|
||||
}
|
||||
|
||||
export interface RequestTicketExtensionPayload {
|
||||
@@ -80,12 +78,6 @@ export interface TicketExtensionRequestsResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ReviewRepairPayload {
|
||||
defect_class: string;
|
||||
action: 'approve' | 'reject';
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface CloseTicketPayload {
|
||||
defect_class: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TicketDefectClassTicketStatus } from './defect-class';
|
||||
import type { TicketStatus } from './status';
|
||||
import type { TicketAssignmentCriteria } from './actions';
|
||||
|
||||
export interface TicketActor {
|
||||
user_id: number | null;
|
||||
@@ -114,6 +115,28 @@ export interface TicketDetectionClassCount {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface TicketAssignmentSummary {
|
||||
id: number;
|
||||
assigned_to_user_id: number;
|
||||
assigned_to_name: string | null;
|
||||
assigned_to_email?: string | null;
|
||||
assigned_to_avatar_url?: string | null;
|
||||
defect_class: string | null;
|
||||
criteria: TicketAssignmentCriteria | null;
|
||||
item_count: number;
|
||||
approved_detections: number;
|
||||
pending_detections: number;
|
||||
not_submitted_detections: number;
|
||||
is_complete: boolean;
|
||||
due_at: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export interface TicketAssignmentsResponse {
|
||||
items: TicketAssignmentSummary[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TicketOverviewDetail {
|
||||
id: string;
|
||||
ticket_name?: string | null;
|
||||
@@ -134,6 +157,7 @@ export interface TicketOverviewDetail {
|
||||
available_defect_classes: string[];
|
||||
detections_by_class: TicketDetectionClassCount[];
|
||||
};
|
||||
assignments?: TicketAssignmentSummary[];
|
||||
timestamps?: TicketTimestamps | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,4 +15,8 @@ export const ROUTES = {
|
||||
ACCOUNT: '/account',
|
||||
UPLOAD: '/upload',
|
||||
TICKET: '/ticket',
|
||||
TICKET_DETAIL: (ticketId: string) => `/ticket/${ticketId}`,
|
||||
TICKET_ASSIGN: (ticketId: string) => `/ticket/${ticketId}/assign`,
|
||||
TICKET_CLASS_REVIEW: (ticketId: string, defectClass: string) =>
|
||||
`/ticket/${ticketId}/${defectClass}/review`,
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user