feat(tickets): add repair proof status filter for class issues
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ImageIcon,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -10,12 +16,15 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DetectionRepairProof } from '@/types';
|
||||
|
||||
import {
|
||||
useDiscardDetectionMutation,
|
||||
useTicketClassDetectionsQuery,
|
||||
} from '../../hooks/useTicketQueries';
|
||||
import { DetectionDiscardDialog } from './DetectionDiscardDialog';
|
||||
import { RepairProofGallery } from './repair/RepairProofGallery';
|
||||
import { RepairSubmittedBanner } from './repair/RepairSubmittedBanner';
|
||||
|
||||
interface TicketClassDetectionPreviewProps {
|
||||
ticketId: string;
|
||||
@@ -54,6 +63,44 @@ function DetectionMetadataBar({
|
||||
);
|
||||
}
|
||||
|
||||
function DetectionRepairProofPanel({
|
||||
repairProof,
|
||||
}: {
|
||||
repairProof: DetectionRepairProof;
|
||||
}) {
|
||||
if (!repairProof.submitted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4 rounded-lg border bg-muted/20 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="size-5 text-primary" />
|
||||
<h4 className="font-semibold">Repair Proof</h4>
|
||||
</div>
|
||||
|
||||
{repairProof.submitted_at ? (
|
||||
<RepairSubmittedBanner
|
||||
submittedAt={repairProof.submitted_at}
|
||||
submitterName="Ticket Worker"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<RepairProofGallery
|
||||
imageUrls={repairProof.proof_image_urls}
|
||||
maxVisibleImages={4}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-foreground">Repair Notes</p>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
|
||||
{repairProof.notes?.trim() || 'No repair notes provided.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TicketClassDetectionPreviewSkeleton() {
|
||||
return (
|
||||
<>
|
||||
@@ -131,12 +178,6 @@ export function TicketClassDetectionPreview({
|
||||
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) {
|
||||
@@ -249,11 +290,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>
|
||||
@@ -265,7 +308,7 @@ export function TicketClassDetectionPreview({
|
||||
label={`${displayName} - Frame ${activeDetection.frame.number}`}
|
||||
title="Location on Map"
|
||||
variant="plain"
|
||||
aspectRatio={mediaAspectRatio}
|
||||
aspectRatio="16 / 9"
|
||||
showCoordinates={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -287,6 +330,12 @@ export function TicketClassDetectionPreview({
|
||||
]}
|
||||
/>
|
||||
|
||||
{activeDetection.repair_proof ? (
|
||||
<DetectionRepairProofPanel
|
||||
repairProof={activeDetection.repair_proof}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
||||
import { NoTicketAction } from './actions/NoTicketAction';
|
||||
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
||||
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
||||
import { SubmittedRepairProofAction } from './actions/SubmittedRepairProofAction';
|
||||
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
||||
|
||||
interface TicketStatusActionsProps {
|
||||
@@ -72,15 +71,5 @@ function getTicketAction(ticket: TicketDetail) {
|
||||
}
|
||||
|
||||
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
|
||||
const action = getTicketAction(ticket);
|
||||
const submittedProof = ticket.repair?.submitted_at ? (
|
||||
<SubmittedRepairProofAction ticket={ticket} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{submittedProof}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
return <div className="space-y-3">{getTicketAction(ticket)}</div>;
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { RepairProofSummary as SubmittedRepairProofAction } from '../repair/RepairProofSummary';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
interface RepairSubmittedBannerProps {
|
||||
submittedAt: string;
|
||||
submitterName: string;
|
||||
}
|
||||
|
||||
export function RepairSubmittedBanner({
|
||||
submittedAt,
|
||||
submitterName,
|
||||
}: RepairSubmittedBannerProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-3 dark:border-emerald-900/60 dark:bg-emerald-950/30">
|
||||
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
|
||||
Repair Submitted
|
||||
</p>
|
||||
<p className="text-xs text-emerald-700/80 dark:text-emerald-300/80">
|
||||
{formatDate(submittedAt)} by {submitterName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,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>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,16 @@ 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;
|
||||
};
|
||||
|
||||
export type DetectionResultItem = {
|
||||
id: string;
|
||||
detection: {
|
||||
@@ -42,6 +52,7 @@ export type DetectionResultItem = {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
};
|
||||
repair_proof: DetectionRepairProof | null;
|
||||
};
|
||||
|
||||
export type DetectionResultLog = DetectionResultItem & {
|
||||
|
||||
Reference in New Issue
Block a user