4 Commits

37 changed files with 1303 additions and 653 deletions

61
package-lock.json generated
View File

@@ -28,7 +28,9 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"leaflet": "^1.9.4",
"libphonenumber-js": "^1.13.7", "libphonenumber-js": "^1.13.7",
"lightgallery": "^2.9.0",
"lucide-react": "^0.548.0", "lucide-react": "^0.548.0",
"motion": "^12.38.0", "motion": "^12.38.0",
"next": "16.0.10", "next": "16.0.10",
@@ -38,6 +40,7 @@
"react-day-picker": "^10.0.1", "react-day-picker": "^10.0.1",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.79.0", "react-hook-form": "^7.79.0",
"react-leaflet": "^5.0.0",
"recharts": "2.15.4", "recharts": "2.15.4",
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
@@ -47,6 +50,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.9", "@tailwindcss/postcss": "^4.1.9",
"@types/leaflet": "^1.9.21",
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -2889,6 +2893,17 @@
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -3376,6 +3391,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3390,6 +3412,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.19.15", "version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
@@ -6856,6 +6888,12 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -6876,6 +6914,15 @@
"integrity": "sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==", "integrity": "sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lightgallery": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/lightgallery/-/lightgallery-2.9.0.tgz",
"integrity": "sha512-58Ud1DyhD2ao58t+kPEqSZrjFxg23tGd5ZKr75erm7q31g5xhUtWUJH3sTUkhHzlyJAKHj5eTrJ37HQRXG4Wbg==",
"license": "GPLv3",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.31.1", "version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
@@ -8058,6 +8105,20 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^3.0.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",

View File

@@ -32,7 +32,9 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"leaflet": "^1.9.4",
"libphonenumber-js": "^1.13.7", "libphonenumber-js": "^1.13.7",
"lightgallery": "^2.9.0",
"lucide-react": "^0.548.0", "lucide-react": "^0.548.0",
"motion": "^12.38.0", "motion": "^12.38.0",
"next": "16.0.10", "next": "16.0.10",
@@ -42,6 +44,7 @@
"react-day-picker": "^10.0.1", "react-day-picker": "^10.0.1",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.79.0", "react-hook-form": "^7.79.0",
"react-leaflet": "^5.0.0",
"recharts": "2.15.4", "recharts": "2.15.4",
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
@@ -51,6 +54,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.9", "@tailwindcss/postcss": "^4.1.9",
"@types/leaflet": "^1.9.21",
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

View File

@@ -0,0 +1,280 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, ChevronRight } from 'lucide-react';
import DetectionLocationMap from '@/components/map/detectionLocationMap';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import { cn } from '@/lib/utils';
import { useTicketClassDetectionsQuery } from '../../hooks/useTicketQueries';
interface TicketClassDetectionPreviewProps {
videoId?: string | null;
defectClassName: string;
displayName: string;
totalCount?: number;
}
function DetectionMetadataBar({
items,
}: {
items: { label: string; value: string | number }[];
}) {
return (
<div className="rounded-lg bg-muted/40 px-4 py-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:divide-x sm:divide-border/70">
{items.map((item, index) => (
<div
key={item.label}
className={cn(
'space-y-1',
index === 0 && 'sm:pr-4',
index === 1 && 'sm:px-4',
index === 2 && 'sm:pl-4',
)}
>
<p className="text-xs text-muted-foreground">{item.label}</p>
<div className="text-sm font-semibold text-foreground">
{item.value}
</div>
</div>
))}
</div>
</div>
);
}
function TicketClassDetectionPreviewSkeleton() {
return (
<>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="min-w-0 space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="aspect-video w-full rounded-lg" />
</div>
<div className="min-w-0 space-y-2">
<Skeleton className="h-3 w-28" />
<Skeleton
className="w-full rounded-lg"
style={{ aspectRatio: '16 / 9' }}
/>
</div>
</div>
<div className="rounded-lg bg-muted/40 px-4 py-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:divide-x sm:divide-border/70">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className={cn(
'space-y-1',
index === 0 && 'sm:pr-4',
index === 1 && 'sm:px-4',
index === 2 && 'sm:pl-4',
)}
>
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
</div>
</>
);
}
export function TicketClassDetectionPreview({
videoId,
defectClassName,
displayName,
totalCount,
}: TicketClassDetectionPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const visual = getDefectVisual(defectClassName);
const IssueIcon = visual.icon;
useEffect(() => {
setCurrentIndex(0);
}, [defectClassName]);
const queryParams = useMemo(
() => ({
class_name: defectClassName,
skip: currentIndex,
limit: 1,
sort: 'timestamp_asc',
}),
[currentIndex, defectClassName],
);
const detectionsQuery = useTicketClassDetectionsQuery(
videoId ?? undefined,
queryParams,
);
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) {
setCurrentIndex(0);
return;
}
if (detectionCount > 0 && currentIndex > detectionCount - 1) {
setCurrentIndex(detectionCount - 1);
}
}, [currentIndex, detectionCount]);
const isPreviousDisabled = currentIndex === 0 || detectionsQuery.isLoading;
const isNextDisabled =
detectionCount === 0 ||
currentIndex + 1 >= detectionCount ||
detectionsQuery.isLoading;
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<span
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full',
visual.cardClassName,
)}
>
<IssueIcon className={cn('size-5', visual.colorClassName)} />
</span>
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="truncate text-lg font-semibold text-foreground">
{displayName}
</h3>
<Badge
variant="secondary"
className={cn(
'rounded-full border-0 px-2.5 py-1 text-xs font-semibold',
visual.cardClassName,
visual.colorClassName,
)}
>
{detectionCount} Detections
</Badge>
</div>
<p className="text-sm text-muted-foreground">
Review detections in ascending timestamp order.
</p>
</div>
</div>
<div className="flex items-center gap-2 self-start sm:self-auto">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCurrentIndex((index) => Math.max(index - 1, 0))}
disabled={isPreviousDisabled}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<div className="min-w-16 text-center text-sm font-medium text-foreground">
{detectionCount === 0
? '0 of 0'
: `${currentIndex + 1} of ${detectionCount}`}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCurrentIndex((index) => index + 1)}
disabled={isNextDisabled}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
{detectionsQuery.isLoading && !detectionsQuery.data ? (
<TicketClassDetectionPreviewSkeleton />
) : detectionsQuery.isError ? (
<div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-8 text-center">
<div className="flex flex-col items-center gap-2">
<AlertTriangle className="size-6 text-destructive" />
<p className="text-sm font-medium text-destructive">
Failed to load detection preview.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void detectionsQuery.refetch()}
>
Retry
</Button>
</div>
</div>
) : !activeDetection || !detectionResult ? (
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
No detections are available for this issue.
</div>
) : (
<>
<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-xs font-medium text-muted-foreground">
Detection Image
</p>
<div className="overflow-hidden rounded-lg">
<AnnotatedDetectionImage
detection={activeDetection}
videoWidth={detectionResult.video_width}
videoHeight={detectionResult.video_height}
/>
</div>
</div>
<DetectionLocationMap
className="min-w-0"
latitude={activeDetection.location.latitude}
longitude={activeDetection.location.longitude}
label={`${displayName} - Frame ${activeDetection.frame.number}`}
title="Location on Map"
variant="plain"
aspectRatio={mediaAspectRatio}
showCoordinates={false}
/>
</div>
<DetectionMetadataBar
items={[
{
label: 'Confidence',
value: `${Math.round(activeDetection.detection.confidence * 100)}%`,
},
{
label: 'Frame',
value: activeDetection.frame.number,
},
{
label: 'Timestamp',
value: `${activeDetection.frame.timestamp_seconds}s`,
},
]}
/>
</>
)}
</div>
);
}

View File

@@ -2,22 +2,26 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { AlertTriangle, Component } from 'lucide-react'; import { Component, ListChecks } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DEFECT_CLASS_STATUS_CONFIG } from '@/constants/defectClassStatus'; import { DEFECT_CLASS_STATUS_CONFIG } from '@/constants/defectClassStatus';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { TicketOverviewDetail } from '@/types';
import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries'; import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
import { TicketClassDetectionPreview } from './TicketClassDetectionPreview';
interface TicketDefectClassTabsProps { interface TicketDefectClassTabsProps {
ticketId: string; ticketId: string;
ticket?: TicketOverviewDetail;
} }
export function TicketDefectClassTabs({ export function TicketDefectClassTabs({
ticketId, ticketId,
ticket,
}: TicketDefectClassTabsProps) { }: TicketDefectClassTabsProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -56,20 +60,32 @@ export function TicketDefectClassTabs({
setSelectedClass(className); setSelectedClass(className);
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false }); 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) { if (defectClassesQuery.isLoading) {
return ( return (
<Card className="w-full"> <Card className="w-full">
<CardHeader> <CardContent className="space-y-4 p-5">
<div className="h-5 w-24 animate-pulse rounded bg-muted" /> <div className="h-5 w-24 animate-pulse rounded bg-muted" />
</CardHeader> <div className="flex flex-col gap-2">
<CardContent className="flex flex-col gap-2"> {Array.from({ length: 3 }).map((_, index) => (
{Array.from({ length: 3 }).map((_, index) => ( <div
<div key={index}
key={index} className="h-11 w-full animate-pulse rounded-lg bg-muted"
className="h-11 w-full animate-pulse rounded-lg bg-muted" />
/> ))}
))} </div>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -87,49 +103,59 @@ export function TicketDefectClassTabs({
className="w-full" className="w-full"
> >
<Card className="w-full"> <Card className="w-full">
<CardHeader> <CardContent className="grid gap-0 p-0 lg:grid-cols-[minmax(220px,280px)_1fr] lg:divide-x lg:divide-border">
<CardTitle className="flex items-center gap-2 text-base"> <div className="space-y-4 p-5">
<AlertTriangle className="size-5 shrink-0 text-primary" /> <div className="flex items-center gap-2">
<span>Issues</span> <ListChecks className="size-5 shrink-0 text-primary" />
<Badge <h2 className="text-base font-semibold">Detected Issues</h2>
variant="secondary" <Badge
className="min-w-5 min-h-5 justify-center rounded-full" variant="secondary"
> className="min-w-5 min-h-5 justify-center rounded-full"
{defectClasses.length} >
</Badge> {defectClasses.length}
</CardTitle> </Badge>
</CardHeader> </div>
<CardContent>
<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 ( <TabsList className="flex h-auto w-full flex-col items-stretch gap-2 bg-transparent p-0">
<TabsTrigger {defectClasses.map((item) => {
key={item.class_name} const statusConfig =
value={item.class_name} DEFECT_CLASS_STATUS_CONFIG[item.assignment_status];
className={cn(
'group h-auto w-full flex-none items-center justify-between! gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground shadow-none after:hidden', return (
'data-[state=active]:bg-muted data-[state=active]:text-foreground', <TabsTrigger
)} key={item.class_name}
> value={item.class_name}
<span className="min-w-0 truncate text-left font-medium">
{item.display_name}
</span>
<Badge
className={cn( className={cn(
'shrink-0 gap-1.5 rounded-full border-0 px-2.5 py-1 text-xs font-medium', 'group h-auto w-full flex-none items-center justify-between! gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground shadow-none after:hidden',
statusConfig.badgeClassName, 'data-[state=active]:bg-muted data-[state=active]:text-foreground',
)} )}
> >
<Component className="size-3" /> <span className="min-w-0 truncate text-left font-medium">
{statusConfig.label} {item.display_name}
</Badge> </span>
</TabsTrigger> <Badge
); className={cn(
})} 'shrink-0 gap-1.5 rounded-full border-0 px-2.5 py-1 text-xs font-medium',
</TabsList> statusConfig.badgeClassName,
)}
>
<Component className="size-3" />
{statusConfig.label}
</Badge>
</TabsTrigger>
);
})}
</TabsList>
</div>
<div className="min-w-0 p-5">
<TicketClassDetectionPreview
videoId={previewVideoId}
defectClassName={selectedClass}
displayName={previewDisplayName}
totalCount={selectedIssue?.count}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
</Tabs> </Tabs>

View File

@@ -12,7 +12,7 @@ export function TicketDetailHeader({
ticket: TicketOverviewDetail; ticket: TicketOverviewDetail;
}) { }) {
const router = useRouter(); const router = useRouter();
const ticketLabel = ticket.ticket_name; const ticketLabel = ticket.ticket_name || ticket.id;
return ( return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">

View File

@@ -6,7 +6,6 @@ import {
CalendarDays, CalendarDays,
Clock3, Clock3,
Gauge, Gauge,
LayoutDashboard,
MapPin, MapPin,
Monitor, Monitor,
UserRound, UserRound,
@@ -62,13 +61,11 @@ function SummaryCard({
value, value,
colorClassName, colorClassName,
cardClassName, cardClassName,
icon: Icon,
}: { }: {
label: string; label: string;
value: number; value: number;
colorClassName: string; colorClassName: string;
cardClassName: string; cardClassName: string;
icon?: IconComponent;
}) { }) {
return ( return (
<div <div
@@ -103,81 +100,70 @@ function VideoMetric({
); );
} }
function OverviewStat({
label,
value,
valueClassName,
icon: Icon,
}: {
label: string;
value: React.ReactNode;
valueClassName?: string;
icon: IconComponent;
}) {
return (
<div className="flex items-center justify-between gap-3 rounded-xl bg-muted/40 p-4 transition-colors hover:bg-muted/60">
<div className="min-w-0">
<MetaLabel>{label}</MetaLabel>
<p
className={cn(
'mt-2 truncate text-lg font-bold tracking-tight text-foreground',
valueClassName,
)}
>
{value}
</p>
</div>
<Icon className={cn('size-5 shrink-0 opacity-80', valueClassName)} />
</div>
);
}
export function TicketOverviewCard({ export function TicketOverviewCard({
ticket, ticket,
defectClass,
}: { }: {
ticket: TicketOverviewDetail; ticket: TicketOverviewDetail;
defectClass?: string;
}) { }) {
const router = useRouter(); const router = useRouter();
const currentIssue = const videoMetadata = ticket.video_metadata;
ticket.ai_result.detections_by_class.find( const duration =
(item) => item.class_name === defectClass, typeof videoMetadata?.duration_seconds === 'number'
) ?? ticket.ai_result.detections_by_class[0]; ? `${Math.floor(videoMetadata.duration_seconds / 60)}:${Math.floor(
const currentVisual = getDefectVisual(currentIssue?.class_name ?? ''); videoMetadata.duration_seconds % 60,
const minutes = Math.floor(ticket.video_metadata.duration_seconds / 60); )
const seconds = Math.floor(ticket.video_metadata.duration_seconds % 60); .toString()
const duration = `${minutes}:${seconds.toString().padStart(2, '0')}`; .padStart(2, '0')}`
: null;
const chainageName = const chainageName =
ticket.location?.chainage?.name || ticket.chainage_name || null; ticket.location?.chainage?.name || ticket.chainage_name || null;
const packageName = ticket.location?.package?.name || null; const packageName = ticket.location?.package?.name || null;
const locationLabel = const locationLabel =
[chainageName, packageName].filter(Boolean).join(', ') || '—'; [chainageName, packageName].filter(Boolean).join(', ') || null;
const uploadedOn =
ticket.timestamps?.created_at || ticket.ai_result.completed_at || null;
const uploaderName = ticket.uploader?.name || null;
const analysisVideoId = ticket.video_id || ticket.video?.id || null;
const videoName = ticket.video?.name || null;
const hasVideoMetrics = Boolean(
videoName ||
videoMetadata?.fps ||
duration ||
videoMetadata?.resolution?.label,
);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<Card> {uploadedOn || uploaderName || locationLabel ? (
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x"> <Card>
<div className="flex items-center gap-3 sm:pr-4"> <CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
<CalendarDays className="size-5 shrink-0 text-muted-foreground" /> {uploadedOn ? (
<OverviewField label="Uploaded On"> <div className="flex items-center gap-3 sm:pr-4">
<MetaValue>{formatDate(ticket.timestamps.created_at)}</MetaValue> <CalendarDays className="size-5 shrink-0 text-muted-foreground" />
</OverviewField> <OverviewField label="Uploaded On">
</div> <MetaValue>{formatDate(uploadedOn)}</MetaValue>
<div className="flex items-center gap-3 sm:px-4"> </OverviewField>
<UserRound className="size-5 shrink-0 text-muted-foreground" /> </div>
<OverviewField label="Uploaded By"> ) : null}
<MetaValue>{ticket.uploader.name}</MetaValue> {uploaderName ? (
</OverviewField> <div className="flex items-center gap-3 sm:px-4">
</div> <UserRound className="size-5 shrink-0 text-muted-foreground" />
<div className="flex items-center gap-3 sm:pl-4"> <OverviewField label="Uploaded By">
<MapPin className="size-5 shrink-0 text-muted-foreground" /> <MetaValue>{uploaderName}</MetaValue>
<OverviewField label="Location"> </OverviewField>
<MetaValue>{locationLabel}</MetaValue> </div>
</OverviewField> ) : null}
</div> {locationLabel ? (
</CardContent> <div className="flex items-center gap-3 sm:pl-4">
</Card> <MapPin className="size-5 shrink-0 text-muted-foreground" />
<OverviewField label="Location">
<MetaValue>{locationLabel}</MetaValue>
</OverviewField>
</div>
) : null}
</CardContent>
</Card>
) : null}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -185,14 +171,14 @@ export function TicketOverviewCard({
<Activity className="size-5 text-primary" /> <Activity className="size-5 text-primary" />
AI Detection Summary AI Detection Summary
</CardTitle> </CardTitle>
{ticket.video_id ? ( {analysisVideoId ? (
<CardAction> <CardAction>
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}> <PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-primary hover:bg-primary/10 hover:text-primary" className="text-primary hover:bg-primary/10 hover:text-primary"
onClick={() => router.push(`/results/${ticket.video_id}`)} onClick={() => router.push(`/results/${analysisVideoId}`)}
> >
View Analysis View Analysis
<ArrowRight className="size-3.5" /> <ArrowRight className="size-3.5" />
@@ -216,53 +202,31 @@ export function TicketOverviewCard({
key={item.class_name} key={item.class_name}
label={item.display_name} label={item.display_name}
value={item.count} value={item.count}
icon={visual.icon}
colorClassName={visual.colorClassName} colorClassName={visual.colorClassName}
cardClassName={visual.cardClassName} cardClassName={visual.cardClassName}
/> />
); );
})} })}
</div> </div>
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3"> {hasVideoMetrics ? (
<VideoMetric <div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3">
icon={Gauge} {videoName ? (
label={`${ticket.video_metadata.fps} FPS`} <VideoMetric icon={Monitor} label={videoName} />
/> ) : null}
<VideoMetric icon={Clock3} label={`${duration} Duration`} /> {videoMetadata?.fps ? (
<VideoMetric <VideoMetric icon={Gauge} label={`${videoMetadata.fps} FPS`} />
icon={Monitor} ) : null}
label={`${ticket.video_metadata.resolution.label} Resolution`} {duration ? (
/> <VideoMetric icon={Clock3} label={`${duration} Duration`} />
</div> ) : null}
</CardContent> {videoMetadata?.resolution?.label ? (
</Card> <VideoMetric
icon={Monitor}
<Card> label={`${videoMetadata.resolution.label} Resolution`}
<CardHeader> />
<CardTitle className="flex items-center gap-2"> ) : null}
<LayoutDashboard className="size-5 text-primary" /> </div>
Ticket Overview ) : null}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
<OverviewStat
icon={currentVisual.icon}
label="Current Issue"
value={currentIssue?.display_name ?? '-'}
valueClassName={currentVisual.colorClassName}
/>
<OverviewStat
icon={Activity}
valueClassName="text-violet-600"
label="Current Issue Count"
value={currentIssue?.count ?? 0}
/>
<OverviewStat
icon={LayoutDashboard}
valueClassName="text-violet-600"
label="Total AI Detections"
value={ticket.ai_result.detection_count}
/>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -1,42 +0,0 @@
'use client';
import { ImageIcon } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { TicketDetail } from '@/types';
import { RepairProofImagesGrid } from './repair-report/RepairProofImagesGrid';
import { RepairReportPanel } from './repair-report/RepairReportPanel';
import { RepairVideoComparison } from './repair-report/RepairVideoComparison';
export function TicketRepairReportCard({ ticket }: { ticket: TicketDetail }) {
const repair = ticket.repair;
if (!repair) {
return null;
}
return (
<section className="space-y-5">
<RepairReportPanel ticket={ticket} />
<RepairVideoComparison ticket={ticket} />
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ImageIcon className="size-5 text-primary" />
Repair Images
</CardTitle>
</CardHeader>
<CardContent>
<RepairProofImagesGrid
imageUrls={repair?.proof_image_urls ?? []}
submittedAt={repair?.submitted_at ?? null}
/>
</CardContent>
</Card>
</section>
);
}

View File

@@ -9,6 +9,7 @@ import { ClosedTicketAction } from './actions/ClosedTicketAction';
import { NoTicketAction } from './actions/NoTicketAction'; import { NoTicketAction } from './actions/NoTicketAction';
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction'; import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
import { ReviewRepairAction } from './actions/ReviewRepairAction'; import { ReviewRepairAction } from './actions/ReviewRepairAction';
import { SubmittedRepairProofAction } from './actions/SubmittedRepairProofAction';
import { SubmitRepairAction } from './actions/SubmitRepairAction'; import { SubmitRepairAction } from './actions/SubmitRepairAction';
interface TicketStatusActionsProps { interface TicketStatusActionsProps {
@@ -67,5 +68,15 @@ function getTicketAction(ticket: TicketDetail) {
} }
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) { export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
return getTicketAction(ticket); const action = getTicketAction(ticket);
const submittedProof = ticket.repair?.submitted_at ? (
<SubmittedRepairProofAction ticket={ticket} />
) : null;
return (
<div className="space-y-3">
{submittedProof}
{action}
</div>
);
} }

View File

@@ -15,6 +15,10 @@ type RepairActionTab = 'repair-proof' | 'request-extension';
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) { export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
const [activeTab, setActiveTab] = useState<RepairActionTab>('repair-proof'); const [activeTab, setActiveTab] = useState<RepairActionTab>('repair-proof');
if (ticket.repair?.submitted_at) {
return null;
}
return ( return (
<TicketActionCard icon={Wrench} title="Repair Proof"> <TicketActionCard icon={Wrench} title="Repair Proof">
<Tabs <Tabs

View File

@@ -0,0 +1 @@
export { RepairProofSummary as SubmittedRepairProofAction } from '../repair/RepairProofSummary';

View File

@@ -1,122 +0,0 @@
'use client';
import { ImageIcon } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
import { cn } from '@/lib/utils';
import { formatDate } from '@/utils/date';
import { ReportSurface } from './repairReportUtils';
const MAX_MEDIA_SLOTS = 5;
const imageBadgeStyles = [
'bg-primary/90 text-primary-foreground',
'bg-secondary text-secondary-foreground',
'bg-accent text-accent-foreground',
'bg-muted text-muted-foreground',
'bg-primary/70 text-primary-foreground',
] as const;
export function RepairProofImagesGrid({
imageUrls,
submittedAt,
layout = 'row',
}: {
imageUrls: string[];
submittedAt: string | null;
layout?: 'row' | 'sidebar';
}) {
const imageSlots = Array.from(
{ length: MAX_MEDIA_SLOTS },
(_, index) => imageUrls[index] ?? null,
);
return (
<div
className={cn(
'grid gap-3',
layout === 'sidebar'
? 'grid-cols-2 auto-rows-fr'
: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
)}
>
{imageSlots.map((url, index) => (
<RepairProofImageSlot
key={index}
url={url}
index={index}
submittedAt={submittedAt}
/>
))}
</div>
);
}
function RepairProofImageSlot({
url,
index,
submittedAt,
}: {
url: string | null;
index: number;
submittedAt: string | null;
}) {
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url);
if (!url) {
return (
<ReportSurface className="space-y-2 p-2">
<div className="flex aspect-4/3 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/10 text-muted-foreground/70">
<ImageIcon className="size-4" />
</div>
<p className="truncate px-1 text-[11px] text-muted-foreground/70">
No image added
</p>
</ReportSurface>
);
}
if (isLoading) {
return (
<ReportSurface className="space-y-2 p-2">
<Skeleton className="aspect-4/3 w-full rounded-lg" />
<Skeleton className="h-3 w-16" />
</ReportSurface>
);
}
if (isError || !objectUrl) {
return (
<ReportSurface className="space-y-2 p-2">
<div className="flex aspect-4/3 items-center justify-center rounded-lg border border-dashed bg-muted/10 text-xs text-muted-foreground">
Unavailable
</div>
<p className="truncate px-1 text-[11px] text-muted-foreground">
Image {index + 1}
</p>
</ReportSurface>
);
}
return (
<ReportSurface className="space-y-2 p-2">
<a
href={objectUrl}
target="_blank"
rel="noreferrer"
className="group relative block overflow-hidden rounded-lg border bg-muted/20"
>
<img
src={objectUrl}
alt={`Repair proof ${index + 1}`}
className="aspect-4/3 w-full object-cover transition-transform group-hover:scale-[1.02]"
/>
</a>
<p className="truncate px-1 text-[11px] text-muted-foreground">
{submittedAt ? formatDate(submittedAt) : `Image ${index + 1}`}
</p>
</ReportSurface>
);
}

View File

@@ -1,106 +0,0 @@
'use client';
import { FileText, MessageSquareQuote } from 'lucide-react';
import { PersonInfo } from '@/components/person-avatar';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { TicketDetail } from '@/types';
import { formatDate } from '@/utils/date';
import { EmptyPanel } from './repairReportUtils';
function MetaLabel({ children }: { children: React.ReactNode }) {
return (
<p className="text-xs font-medium tracking-wide text-muted-foreground">
{children}
</p>
);
}
function ReportField({
label,
value,
}: {
label: string;
value: string | null | undefined;
}) {
return (
<div className="space-y-1">
<MetaLabel>{label}</MetaLabel>
<p className="text-sm font-semibold text-foreground">{value || '-'}</p>
</div>
);
}
export function RepairReportPanel({ ticket }: { ticket: TicketDetail }) {
const repair = ticket.repair;
if (!repair) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="size-5 text-primary" />
Repair Report
</CardTitle>
</CardHeader>
<CardContent>
<EmptyPanel
icon={MessageSquareQuote}
title="No repair report submitted yet"
description="Repair details will appear here once the assigned worker submits evidence."
className="py-10"
/>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="sm:grid-cols-[1fr_auto]">
<CardTitle className="flex items-center gap-2">
<FileText className="size-5 text-primary" />
Repair Report
</CardTitle>
{repair.submitted_at ? (
<span className="text-xs text-muted-foreground italic sm:col-start-2 sm:row-start-1">
Completed {formatDate(repair.submitted_at)}
</span>
) : null}
</CardHeader>
<CardContent className="space-y-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1">
<MetaLabel>Technician</MetaLabel>
<PersonInfo person={ticket.worker} />
</div>
<ReportField
label="Submitted At"
value={
repair.submitted_at ? formatDate(repair.submitted_at) : null
}
/>
</div>
<div className="space-y-2">
<MetaLabel>Repair Summary</MetaLabel>
{repair.notes?.trim() ? (
<div className="rounded-lg border bg-muted/15 px-4 py-3">
<p className="text-sm leading-relaxed text-foreground/90">
{repair.notes}
</p>
</div>
) : (
<EmptyPanel
icon={MessageSquareQuote}
title="No repair notes added yet"
className="py-6"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,78 +0,0 @@
'use client';
import { ShieldCheck } from 'lucide-react';
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { TicketDetail } from '@/types';
export function RepairVideoComparison({ ticket }: { ticket: TicketDetail }) {
const originalVideoUrl = ticket.video?.url ?? null;
const repairVideoUrl = ticket.repair?.proof_video_url ?? null;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="size-5 text-primary" />
Repair Verification
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<ComparisonVideoItem
tooltip={ticket.video?.name ?? 'Original inspection video'}
videoLabel="Before"
videoUrl={originalVideoUrl}
meta="AI detection video"
emptyTitle="No original video available"
emptyDescription="The source inspection video is not attached to this ticket."
/>
<ComparisonVideoItem
tooltip="Contractor repair evidence video"
videoLabel="After"
videoUrl={repairVideoUrl}
meta="Contractor evidence video"
emptyTitle={
ticket.repair
? 'No repair video uploaded'
: 'No evidence submitted yet'
}
emptyDescription={
ticket.repair
? 'The assigned worker has not uploaded a repair video yet.'
: 'Repair video will appear here once evidence is submitted.'
}
/>
</CardContent>
</Card>
);
}
function ComparisonVideoItem({
tooltip,
videoLabel,
videoUrl,
meta,
emptyTitle,
emptyDescription,
}: {
tooltip: string;
videoLabel: string;
videoUrl: string | null;
meta: string;
emptyTitle: string;
emptyDescription: string;
}) {
return (
<div title={tooltip}>
<ProtectedVideoPlayer
url={videoUrl}
label={videoLabel}
meta={meta}
emptyTitle={emptyTitle}
emptyDescription={emptyDescription}
/>
</div>
);
}

View File

@@ -1,56 +0,0 @@
'use client';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
export function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<p className="text-[11px] font-semibold tracking-[0.12em] text-foreground uppercase">
{children}
</p>
);
}
export function ReportSurface({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn('rounded-lg border bg-muted/15 p-3', className)}>
{children}
</div>
);
}
export function EmptyPanel({
icon: Icon,
title,
description,
className,
}: {
icon: LucideIcon;
title: string;
description?: string;
className?: string;
}) {
return (
<div
className={cn(
'flex flex-col items-center justify-center rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center',
className,
)}
>
<Icon className="mb-3 size-6 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">{title}</p>
{description ? (
<p className="mt-1 max-w-xs text-xs text-muted-foreground/80">
{description}
</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { useEffect, useMemo } from 'react';
import { useQueries } from '@tanstack/react-query';
import LightGallery from 'lightgallery/react';
import lgThumbnail from 'lightgallery/plugins/thumbnail';
import lgZoom from 'lightgallery/plugins/zoom';
import { Skeleton } from '@/components/ui/skeleton';
import { protectedMediaService } from '@/services/api';
interface RepairProofGalleryProps {
imageUrls: string[];
}
const MAX_VISIBLE_IMAGES = 8;
export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
const queries = useQueries({
queries: imageUrls.map((url) => ({
queryKey: ['protected-media', 'v2', url],
queryFn: () => protectedMediaService.getBlob(url),
staleTime: Infinity,
gcTime: 1000 * 60 * 30,
})),
});
const blobs = queries.map((query) => query.data);
const objectUrls = useMemo(
() => blobs.map((blob) => (blob ? URL.createObjectURL(blob) : null)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[...blobs],
);
useEffect(
() => () => {
objectUrls.forEach((url) => {
if (url) URL.revokeObjectURL(url);
});
},
[objectUrls],
);
if (queries.some((query) => query.isLoading)) {
return (
<div className="grid grid-cols-4 gap-1 overflow-hidden rounded-xl">
{imageUrls.slice(0, MAX_VISIBLE_IMAGES).map((url, index) => (
<Skeleton
key={`${url}-${index}`}
className="aspect-square rounded-none"
/>
))}
</div>
);
}
const images = objectUrls.flatMap((url, index) =>
url ? [{ url, originalIndex: index }] : [],
);
const visibleCount = Math.min(MAX_VISIBLE_IMAGES, images.length);
const overflowCount = images.length - visibleCount;
if (images.length === 0) {
return (
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
Repair images are unavailable.
</div>
);
}
return (
<LightGallery
elementClassNames="grid grid-cols-4 gap-1 overflow-hidden rounded-xl"
mode="lg-fade"
speed={400}
plugins={[lgThumbnail, lgZoom]}
thumbnail
download={false}
mobileSettings={{ controls: true, showCloseIcon: true, download: false }}
>
{images.map(({ url, originalIndex }, index) => {
const isVisible = index < visibleCount;
const isOverflowTile = overflowCount > 0 && index === visibleCount - 1;
return (
<a
key={imageUrls[originalIndex]}
data-src={url}
data-sub-html={`<p>Repair evidence ${originalIndex + 1}</p>`}
className={isVisible ? 'group relative aspect-square' : 'hidden'}
aria-label={
isOverflowTile
? `View all ${images.length} repair images`
: `View repair image ${originalIndex + 1} of ${images.length}`
}
>
{/* Blob URLs cannot be handled by next/image. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={`Repair evidence ${originalIndex + 1}`}
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>
) : null}
</a>
);
})}
</LightGallery>
);
}

View File

@@ -0,0 +1,103 @@
import { CheckCircle2, HardHat, Wrench } 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">
<Wrench className="size-4 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>
);
}

View File

@@ -17,7 +17,6 @@ import {
import { TicketHistoryCard } from './components/TicketHistoryCard'; import { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketOverviewCard } from './components/TicketOverviewCard'; import { TicketOverviewCard } from './components/TicketOverviewCard';
import { TicketRepairReportCard } from './components/TicketRepairReportCard';
import { TicketStatusActions } from './components/TicketStatusActions'; import { TicketStatusActions } from './components/TicketStatusActions';
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs'; import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents'; import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
@@ -69,21 +68,18 @@ export default function TicketDetailPage() {
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]"> <div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]">
<div className="space-y-5"> <div className="space-y-5">
{overview ? ( {overview ? (
<TicketOverviewCard ticket={overview} defectClass={defectClass} /> <TicketOverviewCard ticket={overview} />
) : ( ) : (
<TicketOverviewCardSkeleton /> <TicketOverviewCardSkeleton />
)} )}
<TicketDefectClassTabs ticketId={ticketId} ticket={overview} />
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null} {isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
{!isClassDetailLoading && classDetail ? ( {!isClassDetailLoading && classDetail ? (
<> <TicketHistoryCard ticket={classDetail} />
<TicketRepairReportCard ticket={classDetail} />
<TicketHistoryCard ticket={classDetail} />
</>
) : null} ) : null}
</div> </div>
<div className="space-y-3 self-start"> <div className="space-y-3 self-start">
<TicketDefectClassTabs ticketId={ticketId} />
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null} {isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
{!isClassDetailLoading && classDetail ? ( {!isClassDetailLoading && classDetail ? (
<TicketStatusActions ticket={classDetail} /> <TicketStatusActions ticket={classDetail} />

View File

@@ -4,7 +4,7 @@ import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ticketService } from '@/services/api'; import { ticketService, videoService } from '@/services/api';
import type { import type {
AssignTicketPayload, AssignTicketPayload,
CloseTicketPayload, CloseTicketPayload,
@@ -15,6 +15,7 @@ import type {
SubmitRepairUploadPayload, SubmitRepairUploadPayload,
TicketDetail, TicketDetail,
TicketListParams, TicketListParams,
VideoDetectionsParams,
} from '@/types'; } from '@/types';
import { ticketKeys } from '../queries/ticketKeys'; import { ticketKeys } from '../queries/ticketKeys';
@@ -85,6 +86,37 @@ export function useTicketDefectClassesQuery(ticketId: string | undefined) {
}); });
} }
export function useTicketClassDetectionsQuery(
videoId: string | undefined,
params: VideoDetectionsParams,
) {
const queryParams = useMemo(
() => ({
skip: params.skip ?? 0,
limit: params.limit ?? 1,
class_name: params.class_name,
min_confidence: params.min_confidence,
sort: params.sort ?? 'timestamp_asc',
search: params.search,
}),
[
params.class_name,
params.limit,
params.min_confidence,
params.search,
params.skip,
params.sort,
],
);
return useQuery({
queryKey: ticketKeys.classDetections(videoId ?? '', queryParams),
queryFn: () =>
videoService.getVideoDetections(videoId as string, queryParams),
enabled: Boolean(videoId && queryParams.class_name),
});
}
export function useTicketExtensionRequestQuery( export function useTicketExtensionRequestQuery(
ticketId: string | undefined, ticketId: string | undefined,
assignmentId: number | undefined, assignmentId: number | undefined,

View File

@@ -1,4 +1,4 @@
import type { TicketListParams } from '@/types'; import type { TicketListParams, VideoDetectionsParams } from '@/types';
export const ticketKeys = { export const ticketKeys = {
all: ['tickets'] as const, all: ['tickets'] as const,
@@ -9,6 +9,8 @@ export const ticketKeys = {
[...ticketKeys.details(), ticketId, 'overview'] as const, [...ticketKeys.details(), ticketId, 'overview'] as const,
classDetail: (ticketId: string, defectClass: string) => classDetail: (ticketId: string, defectClass: string) =>
[...ticketKeys.details(), ticketId, 'class', defectClass] as const, [...ticketKeys.details(), ticketId, 'class', defectClass] as const,
classDetections: (videoId: string, params: VideoDetectionsParams) =>
[...ticketKeys.details(), 'video', videoId, 'detections', params] as const,
defectClasses: (ticketId: string) => defectClasses: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'defect-classes'] as const, [...ticketKeys.details(), ticketId, 'defect-classes'] as const,
extensionRequest: (ticketId: string, assignmentId: number | undefined) => extensionRequest: (ticketId: string, assignmentId: number | undefined) =>

View File

@@ -46,11 +46,7 @@ export function useUploadListColumns(
: undefined : undefined
} }
role={thumbnail ? 'img' : undefined} role={thumbnail ? 'img' : undefined}
aria-label={ aria-label={thumbnail ? 'Upload thumbnail' : undefined}
thumbnail
? `Thumbnail for ${row.original.video_name}`
: undefined
}
> >
{!thumbnail ? ( {!thumbnail ? (
<Video className="size-5 text-muted-foreground" /> <Video className="size-5 text-muted-foreground" />
@@ -59,14 +55,6 @@ export function useUploadListColumns(
); );
}, },
}, },
{
accessorKey: 'video_name',
header: 'Video Name',
size: 150,
cell: ({ row }) => (
<span className="font-medium">{row.original.video_name}</span>
),
},
{ {
id: 'uploaded_by', id: 'uploaded_by',
header: 'Uploader', header: 'Uploader',

View File

@@ -2,6 +2,10 @@ import type { Metadata } from 'next';
import { Geist_Mono, Poppins } from 'next/font/google'; import { Geist_Mono, Poppins } from 'next/font/google';
import '@vidstack/react/player/styles/default/theme.css'; import '@vidstack/react/player/styles/default/theme.css';
import '@vidstack/react/player/styles/default/layouts/video.css'; import '@vidstack/react/player/styles/default/layouts/video.css';
import 'leaflet/dist/leaflet.css';
import 'lightgallery/css/lightgallery.css';
import 'lightgallery/css/lg-thumbnail.css';
import 'lightgallery/css/lg-zoom.css';
import './globals.css'; import './globals.css';
import { ThemeProvider } from '@/providers/ThemeProvider'; import { ThemeProvider } from '@/providers/ThemeProvider';
import AppInitializer from '@/providers/AppInitializer'; import AppInitializer from '@/providers/AppInitializer';

View File

@@ -0,0 +1,66 @@
import { getDefectVisual } from '@/constants/defectVisualConfig';
import type { DetectionResultItem } from '@/types';
interface BoundingBoxOverlayProps {
detections: DetectionResultItem[];
videoWidth: number;
videoHeight: number;
}
export default function BoundingBoxOverlay({
detections,
videoWidth,
videoHeight,
}: BoundingBoxOverlayProps) {
if (videoWidth <= 0 || videoHeight <= 0 || detections.length === 0) {
return null;
}
return (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-[2] size-full"
viewBox={`0 0 ${videoWidth} ${videoHeight}`}
preserveAspectRatio="xMidYMid meet"
>
{detections.map(({ id, detection }) => {
const { bounding_box: box } = detection;
const color = getDefectVisual(detection.class_name).boundingBoxColor;
const label = `${detection.display_name} ${(detection.confidence * 100).toFixed(0)}%`;
const labelY = Math.max(box.y1 - 34, 0);
const labelWidth = Math.max(150, label.length * 17);
return (
<g key={id}>
<rect
x={box.x1}
y={box.y1}
width={box.x2 - box.x1}
height={box.y2 - box.y1}
fill="none"
stroke={color}
strokeWidth="4"
vectorEffect="non-scaling-stroke"
/>
<rect
x={box.x1}
y={labelY}
width={labelWidth}
height="34"
fill={color}
/>
<text
x={box.x1 + 8}
y={labelY + 23}
fill="white"
fontSize="20"
fontWeight="600"
>
{label}
</text>
</g>
);
})}
</svg>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import dynamic from 'next/dynamic';
import { MapPin } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
type DetectionLocationMapProps = {
latitude: number | null;
longitude: number | null;
label: string;
title?: string;
description?: string;
aspectRatio?: string;
showCoordinates?: boolean;
variant?: 'card' | 'plain';
className?: string;
};
type ClientDetectionLocationMapProps = {
latitude: number;
longitude: number;
label: string;
};
const ClientDetectionLocationMap = dynamic<ClientDetectionLocationMapProps>(
async () => {
const { CircleMarker, MapContainer, Popup, TileLayer } =
await import('react-leaflet');
return function ClientDetectionLocationMapInner({
latitude,
longitude,
label,
}: ClientDetectionLocationMapProps) {
return (
<MapContainer
key={`${latitude}:${longitude}`}
center={[latitude, longitude]}
zoom={17}
scrollWheelZoom={false}
className="h-full w-full"
>
<TileLayer
attribution="&copy; OpenStreetMap contributors"
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<CircleMarker
center={[latitude, longitude]}
radius={10}
pathOptions={{
color: '#dc2626',
fillColor: '#dc2626',
fillOpacity: 0.75,
weight: 2,
}}
>
<Popup>{label}</Popup>
</CircleMarker>
</MapContainer>
);
};
},
{
ssr: false,
loading: () => <div className="h-full w-full animate-pulse bg-muted" />,
},
);
export default function DetectionLocationMap({
latitude,
longitude,
label,
title = 'Detection Location',
description = 'Selected log GPS coordinates',
aspectRatio = '16 / 9',
showCoordinates = true,
variant = 'card',
className,
}: DetectionLocationMapProps) {
const mapContent =
latitude == null || longitude == null ? (
<div
className="flex items-center justify-center rounded-lg border border-dashed bg-muted/30 px-4 py-8 text-sm text-muted-foreground"
style={{ aspectRatio }}
>
Coordinates are unavailable for the selected log.
</div>
) : (
<>
{showCoordinates ? (
<div className="text-sm text-muted-foreground">
{latitude.toFixed(6)}, {longitude.toFixed(6)}
</div>
) : null}
<div
className="overflow-hidden rounded-lg border bg-muted"
style={{ aspectRatio }}
>
<ClientDetectionLocationMap
latitude={latitude}
longitude={longitude}
label={label}
/>
</div>
</>
);
if (variant === 'plain') {
return (
<div className={cn('space-y-2', className)}>
{title ? (
<p className="text-xs font-medium text-muted-foreground">{title}</p>
) : null}
{mapContent}
</div>
);
}
return (
<Card className={cn('overflow-hidden', className)}>
<CardHeader className="border-b pb-4">
<div className="flex items-center gap-3">
<div className="rounded bg-secondary p-2">
<MapPin className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base font-semibold">{title}</CardTitle>
<CardDescription className="text-xs">{description}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 pt-6">{mapContent}</CardContent>
</Card>
);
}

View File

@@ -24,7 +24,8 @@ export function ProtectedVideoPlayer({
unavailableTitle = 'Video unavailable', unavailableTitle = 'Video unavailable',
className, className,
}: ProtectedVideoPlayerProps) { }: ProtectedVideoPlayerProps) {
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url); const { objectUrl, mediaType, isLoading, isError } =
useProtectedMediaObjectUrl(url);
if (!url) { if (!url) {
return ( return (
@@ -73,11 +74,14 @@ export function ProtectedVideoPlayer({
)} )}
> >
<video <video
src={objectUrl} key={objectUrl}
className="aspect-video w-full object-cover" className="aspect-video w-full bg-black object-contain"
controls controls
playsInline playsInline
/> preload="metadata"
>
<source src={objectUrl} type={mediaType ?? 'video/mp4'} />
</video>
{label ? ( {label ? (
<span className="absolute top-3 left-3 rounded bg-black/65 px-2.5 py-1 text-[11px] font-semibold text-white backdrop-blur-sm"> <span className="absolute top-3 left-3 rounded bg-black/65 px-2.5 py-1 text-[11px] font-semibold text-white backdrop-blur-sm">
@@ -86,7 +90,7 @@ export function ProtectedVideoPlayer({
) : null} ) : null}
{meta ? ( {meta ? (
<span className="pointer-events-none absolute top-3 right-3 max-w-[55%] truncate text-xs font-medium"> <span className="pointer-events-none absolute top-3 right-3 max-w-[55%] truncate text-xs font-medium text-white/90">
{meta} {meta}
</span> </span>
) : null} ) : null}

View File

@@ -0,0 +1,69 @@
'use client';
import { AlertTriangle, ImageIcon, Loader2 } from 'lucide-react';
import Image from 'next/image';
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
import type { DetectionResultItem } from '@/types';
import BoundingBoxOverlay from '@/components/annotation/boundingBoxOverlay';
interface AnnotatedDetectionImageProps {
detection?: DetectionResultItem;
videoWidth: number;
videoHeight: number;
}
export default function AnnotatedDetectionImage({
detection,
videoWidth,
videoHeight,
}: AnnotatedDetectionImageProps) {
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(
detection?.detection.context_image_url,
);
const aspectRatio =
videoWidth > 0 && videoHeight > 0
? `${videoWidth} / ${videoHeight}`
: '16 / 9';
return (
<div
className="relative flex w-full items-center justify-center overflow-hidden rounded-lg border bg-black"
style={{ aspectRatio }}
>
{!detection ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<ImageIcon className="size-7" />
<p className="text-sm">Select a detection to view its image.</p>
</div>
) : isLoading ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="size-7 animate-spin text-primary" />
<p className="text-sm">Loading detection image...</p>
</div>
) : isError || !objectUrl ? (
<div className="flex flex-col items-center gap-2 text-destructive">
<AlertTriangle className="size-7" />
<p className="text-sm">Failed to load the detection image.</p>
</div>
) : (
<>
<Image
src={objectUrl}
alt={`${detection.detection.display_name} detection`}
fill
unoptimized
className="object-contain"
/>
<BoundingBoxOverlay
detections={[detection]}
videoWidth={videoWidth}
videoHeight={videoHeight}
/>
</>
)}
</div>
);
}

View File

@@ -1,14 +1,17 @@
'use client'; 'use client';
import { RefObject } from 'react'; import { RefObject, useEffect } from 'react';
import { AlertTriangle, Loader2 } from 'lucide-react'; import { AlertTriangle, Loader2 } from 'lucide-react';
import type { DetectionResultLog } from '@/types'; import type { DetectionResultLog } from '@/types';
import { useDetectionThumbnails } from './useDetectionThumbnails'; import { useDetectionThumbnails } from './useDetectionThumbnails';
import BoundingBoxOverlay from '@/components/annotation/boundingBoxOverlay';
import { import {
MediaPlayer, MediaPlayer,
MediaProvider, MediaProvider,
Poster, Poster,
isVideoProvider,
type MediaPlayerInstance, type MediaPlayerInstance,
useMediaProvider,
} from '@vidstack/react'; } from '@vidstack/react';
import { import {
DefaultVideoLayout, DefaultVideoLayout,
@@ -18,22 +21,54 @@ import {
interface AnnotatedVideoPlayerProps { interface AnnotatedVideoPlayerProps {
videoRef: RefObject<MediaPlayerInstance | null>; videoRef: RefObject<MediaPlayerInstance | null>;
logs: DetectionResultLog[]; logs: DetectionResultLog[];
visibleLogs: DetectionResultLog[];
videoWidth: number;
videoHeight: number;
videoUrl: string | null; videoUrl: string | null;
isLoading: boolean; isLoading: boolean;
isError: boolean; isError: boolean;
onRetry: () => void; onRetry: () => void;
onTimeUpdate: () => void; onTimeUpdate: () => void;
onVideoFrame: (mediaTime: number) => void;
onSeeked: () => void; onSeeked: () => void;
} }
function VideoFrameSync({
onVideoFrame,
}: Pick<AnnotatedVideoPlayerProps, 'onVideoFrame'>) {
const provider = useMediaProvider();
useEffect(() => {
if (!isVideoProvider(provider)) return;
const video = provider.video;
let callbackId: number;
const update = (_now: number, metadata: VideoFrameCallbackMetadata) => {
onVideoFrame(metadata.mediaTime);
callbackId = video.requestVideoFrameCallback(update);
};
callbackId = video.requestVideoFrameCallback(update);
return () => video.cancelVideoFrameCallback(callbackId);
}, [onVideoFrame, provider]);
return null;
}
export default function AnnotatedVideoPlayer({ export default function AnnotatedVideoPlayer({
videoRef, videoRef,
logs, logs,
visibleLogs,
videoWidth,
videoHeight,
videoUrl, videoUrl,
isLoading, isLoading,
isError, isError,
onRetry, onRetry,
onTimeUpdate, onTimeUpdate,
onVideoFrame,
onSeeked, onSeeked,
}: AnnotatedVideoPlayerProps) { }: AnnotatedVideoPlayerProps) {
const thumbnails = useDetectionThumbnails(logs); const thumbnails = useDetectionThumbnails(logs);
@@ -55,6 +90,12 @@ export default function AnnotatedVideoPlayer({
<MediaProvider> <MediaProvider>
<Poster className="vds-poster" /> <Poster className="vds-poster" />
</MediaProvider> </MediaProvider>
<VideoFrameSync onVideoFrame={onVideoFrame} />
<BoundingBoxOverlay
detections={visibleLogs}
videoWidth={videoWidth}
videoHeight={videoHeight}
/>
<DefaultVideoLayout <DefaultVideoLayout
thumbnails={thumbnails} thumbnails={thumbnails}
icons={defaultLayoutIcons} icons={defaultLayoutIcons}

View File

@@ -12,7 +12,7 @@ import type { DetectionResultLog } from '@/types';
interface DetectionLogsProps { interface DetectionLogsProps {
logs: DetectionResultLog[]; logs: DetectionResultLog[];
activeLogId?: string; activeLogId?: string;
onSeek: (log: DetectionResultLog) => void; onSelect: (log: DetectionResultLog) => void;
} }
const formatVideoTime = (seconds: number) => { const formatVideoTime = (seconds: number) => {
@@ -63,7 +63,7 @@ function DetectionLogThumbnail({
); );
} }
const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => { const DetectionLogs = ({ logs, activeLogId, onSelect }: DetectionLogsProps) => {
const activeLogRef = useRef<HTMLButtonElement | null>(null); const activeLogRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => { useEffect(() => {
@@ -100,7 +100,7 @@ const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => {
key={`${log.id}-${log.frame.number}`} key={`${log.id}-${log.frame.number}`}
ref={isActive ? activeLogRef : null} ref={isActive ? activeLogRef : null}
type="button" type="button"
onClick={() => onSeek(log)} onClick={() => onSelect(log)}
className={cn( className={cn(
'w-full rounded-md border bg-card p-3 text-left transition-colors hover:border-primary', 'w-full rounded-md border bg-card p-3 text-left transition-colors hover:border-primary',
isActive && 'border-primary bg-primary/5', isActive && 'border-primary bg-primary/5',

View File

@@ -1,16 +1,18 @@
'use client'; 'use client';
import { RefObject, useMemo, useRef, useState } from 'react'; import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
import type { MediaPlayerInstance } from '@vidstack/react'; import type { MediaPlayerInstance } from '@vidstack/react';
import { CompletedVideoResult, DetectionResultLog } from '@/types'; import { CompletedVideoResult, DetectionResultLog } from '@/types';
interface UseDetectionPlaybackParams { interface UseDetectionPlaybackParams {
logs: CompletedVideoResult['logs']; logs: CompletedVideoResult['logs'];
fps: number;
videoRef: RefObject<MediaPlayerInstance | null>; videoRef: RefObject<MediaPlayerInstance | null>;
} }
export function useDetectionPlayback({ export function useDetectionPlayback({
logs, logs,
fps,
videoRef, videoRef,
}: UseDetectionPlaybackParams) { }: UseDetectionPlaybackParams) {
const shouldPauseAfterSeekRef = useRef(false); const shouldPauseAfterSeekRef = useRef(false);
@@ -24,6 +26,36 @@ export function useDetectionPlayback({
const [activeLog, setActiveLog] = useState<DetectionResultLog | undefined>( const [activeLog, setActiveLog] = useState<DetectionResultLog | undefined>(
sortedLogs[0], sortedLogs[0],
); );
const [visibleLogs, setVisibleLogs] = useState<DetectionResultLog[]>([]);
const visibleLogIdsRef = useRef('');
const getVisibleLogs = useCallback(
(currentTime: number) => {
const frameDuration = fps > 0 ? 1 / fps : 0;
return sortedLogs.filter((log) => {
const start =
log.frame.start_timestamp_seconds ?? log.frame.timestamp_seconds;
const end = log.frame.end_timestamp_seconds ?? start + frameDuration;
return currentTime >= start && currentTime < end;
});
},
[fps, sortedLogs],
);
const updateVisibleLogs = useCallback(
(currentTime: number) => {
const nextLogs = getVisibleLogs(currentTime);
const nextIds = nextLogs.map((log) => log.id).join('|');
if (nextIds === visibleLogIdsRef.current) return;
visibleLogIdsRef.current = nextIds;
setVisibleLogs(nextLogs);
},
[getVisibleLogs],
);
const handleSeek = (log: DetectionResultLog) => { const handleSeek = (log: DetectionResultLog) => {
const video = videoRef.current; const video = videoRef.current;
@@ -33,6 +65,7 @@ export function useDetectionPlayback({
video.pause(); video.pause();
video.currentTime = log.frame.timestamp_seconds; video.currentTime = log.frame.timestamp_seconds;
setActiveLog(log); setActiveLog(log);
updateVisibleLogs(log.frame.timestamp_seconds);
}; };
const handleSeeked = () => { const handleSeeked = () => {
@@ -58,9 +91,11 @@ export function useDetectionPlayback({
return { return {
activeLog, activeLog,
visibleLogs,
sortedLogs, sortedLogs,
handleSeek, handleSeek,
handleSeeked, handleSeeked,
handleTimeUpdate, handleTimeUpdate,
handleVideoFrame: updateVisibleLogs,
}; };
} }

View File

@@ -1,8 +1,7 @@
'use client'; 'use client';
import { useRef } from 'react'; import { useMemo, useState } from 'react';
import { Film } from 'lucide-react'; import { ImageIcon } from 'lucide-react';
import type { MediaPlayerInstance } from '@vidstack/react';
import { import {
Card, Card,
CardContent, CardContent,
@@ -11,30 +10,27 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { CompletedVideoResult } from '@/types'; import { CompletedVideoResult } from '@/types';
import { useAnnotatedVideoQuery } from '@/app/(modules)/results/hooks/useVideoResults'; import DetectionLocationMap from './map/detectionLocationMap';
import AnnotatedVideoPlayer from './video/annotatedVideoPlayer'; import AnnotatedDetectionImage from './video/annotatedDetectionImage';
import CurrentDetectionBar from './video/currentDetectionBar'; import CurrentDetectionBar from './video/currentDetectionBar';
import DetectionLogs from './video/detectionLogs'; import DetectionLogs from './video/detectionLogs';
import ResultStatsGrid from './video/resultStatsGrid'; import ResultStatsGrid from './video/resultStatsGrid';
import { useDetectionPlayback } from './video/useDetectionPlayback';
type VideoPlayerSectionProps = { type VideoPlayerSectionProps = {
data: CompletedVideoResult; data: CompletedVideoResult;
}; };
export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) { export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
const videoRef = useRef<MediaPlayerInstance>(null); const sortedLogs = useMemo(
const { activeLog, sortedLogs, handleSeek, handleSeeked, handleTimeUpdate } = () =>
useDetectionPlayback({ [...data.logs].sort(
logs: data.logs, (a, b) => a.frame.timestamp_seconds - b.frame.timestamp_seconds,
videoRef, ),
}); [data.logs],
const { );
videoUrl, const [selectedLogId, setSelectedLogId] = useState(sortedLogs[0]?.id);
isLoading: isVideoLoading, const activeLog =
isError: isVideoError, sortedLogs.find((log) => log.id === selectedLogId) ?? sortedLogs[0];
refetch: refetchVideo,
} = useAnnotatedVideoQuery(data.processed_video_url || '');
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -42,14 +38,14 @@ export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
<CardHeader className="border-b pb-4"> <CardHeader className="border-b pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="rounded bg-secondary p-2"> <div className="rounded bg-secondary p-2">
<Film className="h-5 w-5 text-primary" /> <ImageIcon className="h-5 w-5 text-primary" />
</div> </div>
<div> <div>
<CardTitle className="text-lg font-bold"> <CardTitle className="text-lg font-bold">
Detection Playback Detection Preview
</CardTitle> </CardTitle>
<CardDescription className="text-xs"> <CardDescription className="text-xs">
Annotated video with backend detection logs Selected detection image with its bounding box
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@@ -57,15 +53,16 @@ export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2"> <div className="space-y-6 lg:col-span-2">
<AnnotatedVideoPlayer <AnnotatedDetectionImage
videoRef={videoRef} detection={activeLog}
logs={sortedLogs} videoWidth={data.summary.video_width}
videoUrl={videoUrl} videoHeight={data.summary.video_height}
isLoading={isVideoLoading} />
isError={isVideoError}
onRetry={() => void refetchVideo()} <DetectionLocationMap
onTimeUpdate={handleTimeUpdate} latitude={activeLog?.location.latitude ?? null}
onSeeked={handleSeeked} longitude={activeLog?.location.longitude ?? null}
label={activeLog?.detection.display_name ?? 'Detection'}
/> />
<CurrentDetectionBar activeLog={activeLog} data={data} /> <CurrentDetectionBar activeLog={activeLog} data={data} />
@@ -74,7 +71,7 @@ export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
<DetectionLogs <DetectionLogs
logs={sortedLogs} logs={sortedLogs}
activeLogId={activeLog?.id} activeLogId={activeLog?.id}
onSeek={handleSeek} onSelect={(log) => setSelectedLogId(log.id)}
/> />
</div> </div>

View File

@@ -54,6 +54,7 @@ export const API_ROUTES = {
UPLOAD_EVENTS: (token: string) => UPLOAD_EVENTS: (token: string) =>
`/biz/api/v1/uploads/events?sse_token=${encodeURIComponent(token)}`, `/biz/api/v1/uploads/events?sse_token=${encodeURIComponent(token)}`,
RESULTS: (id: string) => `/biz/api/v1/results/${id}/completed`, RESULTS: (id: string) => `/biz/api/v1/results/${id}/completed`,
DETECTIONS: (id: string) => `/biz/api/v1/results/${id}/detections`,
}, },
TICKETS: { TICKETS: {
BASE: '/biz/api/v1/tickets', BASE: '/biz/api/v1/tickets',

View File

@@ -12,6 +12,7 @@ import {
type DefectVisualConfig = { type DefectVisualConfig = {
icon: LucideIcon; icon: LucideIcon;
boundingBoxColor: string;
colorClassName: string; colorClassName: string;
iconClassName: string; iconClassName: string;
cardClassName: string; cardClassName: string;
@@ -20,30 +21,35 @@ type DefectVisualConfig = {
const DEFECT_VISUALS: Record<string, DefectVisualConfig> = { const DEFECT_VISUALS: Record<string, DefectVisualConfig> = {
manhole_cover: { manhole_cover: {
icon: CircleDot, icon: CircleDot,
boundingBoxColor: '#71717a',
colorClassName: 'text-zinc-500', colorClassName: 'text-zinc-500',
iconClassName: 'bg-zinc-500/10 text-zinc-500', iconClassName: 'bg-zinc-500/10 text-zinc-500',
cardClassName: 'bg-zinc-500/5', cardClassName: 'bg-zinc-500/5',
}, },
pothole: { pothole: {
icon: Construction, icon: Construction,
boundingBoxColor: '#f97316',
colorClassName: 'text-orange-500', colorClassName: 'text-orange-500',
iconClassName: 'bg-orange-500/10 text-orange-500', iconClassName: 'bg-orange-500/10 text-orange-500',
cardClassName: 'bg-orange-500/5', cardClassName: 'bg-orange-500/5',
}, },
road_crack: { road_crack: {
icon: Spline, icon: Spline,
boundingBoxColor: '#f43f5e',
colorClassName: 'text-rose-500', colorClassName: 'text-rose-500',
iconClassName: 'bg-rose-500/10 text-rose-500', iconClassName: 'bg-rose-500/10 text-rose-500',
cardClassName: 'bg-rose-500/5', cardClassName: 'bg-rose-500/5',
}, },
sign_board: { sign_board: {
icon: SignpostBig, icon: SignpostBig,
boundingBoxColor: '#3b82f6',
colorClassName: 'text-blue-500', colorClassName: 'text-blue-500',
iconClassName: 'bg-blue-500/10 text-blue-500', iconClassName: 'bg-blue-500/10 text-blue-500',
cardClassName: 'bg-blue-500/5', cardClassName: 'bg-blue-500/5',
}, },
water_puddle: { water_puddle: {
icon: Droplets, icon: Droplets,
boundingBoxColor: '#06b6d4',
colorClassName: 'text-cyan-500', colorClassName: 'text-cyan-500',
iconClassName: 'bg-cyan-500/10 text-cyan-500', iconClassName: 'bg-cyan-500/10 text-cyan-500',
cardClassName: 'bg-cyan-500/5', cardClassName: 'bg-cyan-500/5',
@@ -52,6 +58,7 @@ const DEFECT_VISUALS: Record<string, DefectVisualConfig> = {
const DEFAULT_VISUAL: DefectVisualConfig = { const DEFAULT_VISUAL: DefectVisualConfig = {
icon: Activity, icon: Activity,
boundingBoxColor: '#22c55e',
colorClassName: 'text-green-500', colorClassName: 'text-green-500',
iconClassName: 'bg-green-500/10 text-green-500', iconClassName: 'bg-green-500/10 text-green-500',
cardClassName: 'bg-green-500/5', cardClassName: 'bg-green-500/5',

View File

@@ -1,38 +1,41 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { protectedMediaService } from '@/services/api'; import { protectedMediaService } from '@/services/api';
export function useProtectedMediaObjectUrl(url: string | null | undefined) { export function useProtectedMediaObjectUrl(url: string | null | undefined) {
const query = useQuery({ const query = useQuery({
queryKey: ['protected-media', url], queryKey: ['protected-media', 'v2', url],
queryFn: () => protectedMediaService.getBlob(url as string), queryFn: () => protectedMediaService.getBlob(url as string),
enabled: Boolean(url), enabled: Boolean(url),
staleTime: Infinity, staleTime: Infinity,
gcTime: 1000 * 60 * 30, gcTime: 1000 * 60 * 30,
}); });
const [objectUrl, setObjectUrl] = useState<string | null>(null); const objectUrl = useMemo(() => {
useEffect(() => {
if (!query.data) { if (!query.data) {
setObjectUrl(null); return null;
return;
} }
const nextUrl = URL.createObjectURL(query.data); return URL.createObjectURL(query.data);
setObjectUrl(nextUrl);
return () => {
URL.revokeObjectURL(nextUrl);
};
}, [query.data]); }, [query.data]);
useEffect(() => {
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [objectUrl]);
const isLoading = query.isLoading || (Boolean(query.data) && !objectUrl);
return { return {
objectUrl, objectUrl,
isLoading: query.isLoading, mediaType: query.data?.type ?? null,
isLoading,
isError: query.isError, isError: query.isError,
error: query.error, error: query.error,
}; };

View File

@@ -1,10 +1,49 @@
import axiosClient from '../axios/axios'; import axiosClient from '../axios/axios';
const MIME_BY_EXTENSION: Record<string, string> = {
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
m4v: 'video/mp4',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
};
function normalizeMediaBlob(url: string, blob: Blob): Blob {
if (blob.type && blob.type !== 'application/octet-stream') {
return blob;
}
const extension = url.split('?')[0].split('.').pop()?.toLowerCase();
const mimeType = extension ? MIME_BY_EXTENSION[extension] : undefined;
if (!mimeType) {
return blob;
}
return new Blob([blob], { type: mimeType });
}
export const protectedMediaService = { export const protectedMediaService = {
getBlob: async (url: string): Promise<Blob> => { getBlob: async (url: string): Promise<Blob> => {
const response = await axiosClient.get<Blob>(url, { const response = await axiosClient.get<Blob>(url, {
responseType: 'blob', responseType: 'blob',
headers: {
Accept: '*/*',
},
transformRequest: [
(data, headers) => {
if (headers) {
delete headers['Content-Type'];
}
return data;
},
],
}); });
return response.data;
return normalizeMediaBlob(url, response.data);
}, },
}; };

View File

@@ -5,6 +5,8 @@ import {
PaginationParams, PaginationParams,
SseTokenResponse, SseTokenResponse,
UploadListResponse, UploadListResponse,
VideoDetectionsParams,
VideoDetectionsResponse,
} from '@/types'; } from '@/types';
/** /**
@@ -59,6 +61,26 @@ export const videoService = {
return response.data; return response.data;
}, },
getVideoDetections: async (
videoId: string,
params?: VideoDetectionsParams,
): Promise<VideoDetectionsResponse> => {
const response = await axiosClient.get<VideoDetectionsResponse>(
API_ROUTES.VIDEOS.DETECTIONS(videoId),
{
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 1,
class_name: params?.class_name,
min_confidence: params?.min_confidence,
sort: params?.sort ?? 'timestamp_asc',
search: params?.search,
},
},
);
return response.data;
},
/** /**
* Fetch the annotated video as a blob through the authenticated axios client. * Fetch the annotated video as a blob through the authenticated axios client.
* *

View File

@@ -8,6 +8,8 @@ export type CompletedVideoResult = {
summary: { summary: {
fps: number; fps: number;
duration_seconds: number; duration_seconds: number;
video_width: number;
video_height: number;
total_detections: number; total_detections: number;
}; };
defect_counts: DetectionClassCount[]; defect_counts: DetectionClassCount[];

View File

@@ -5,7 +5,16 @@ export type DetectionClassCount = {
unique_count: number; unique_count: number;
}; };
export type DetectionResultLog = { export type DetectionBoundingBox = {
x1: number;
y1: number;
x2: number;
y2: number;
width: number;
height: number;
};
export type DetectionResultItem = {
id: string; id: string;
detection: { detection: {
id: number; id: number;
@@ -13,15 +22,21 @@ export type DetectionResultLog = {
display_name: string; display_name: string;
confidence: number; confidence: number;
context_image_url: string; context_image_url: string;
bounding_box: DetectionBoundingBox;
}; };
frame: { frame: {
number: number; number: number;
timestamp_seconds: number; timestamp_seconds: number;
start_timestamp_seconds?: number;
end_timestamp_seconds?: number;
}; };
location: { location: {
latitude: number | null; latitude: number | null;
longitude: number | null; longitude: number | null;
}; };
};
export type DetectionResultLog = DetectionResultItem & {
running_counts: { running_counts: {
total: number; total: number;
by_class: Array<{ by_class: Array<{
@@ -31,3 +46,23 @@ export type DetectionResultLog = {
}>; }>;
}; };
}; };
export type VideoDetectionsParams = {
skip?: number;
limit?: number;
class_name?: string | string[];
min_confidence?: number;
sort?: string;
search?: string;
};
export type VideoDetectionsResponse = {
video_id: string;
video_width: number;
video_height: number;
skip: number;
limit: number;
total: number;
sort: string;
items: DetectionResultItem[];
};

View File

@@ -116,24 +116,25 @@ export interface TicketDetectionClassCount {
export interface TicketOverviewDetail { export interface TicketOverviewDetail {
id: string; id: string;
ticket_name: string; ticket_name?: string | null;
video_id: string; video_id?: string | null;
chainage_id: string; video?: TicketVideo | null;
chainage_name: string; chainage_id?: string | null;
chainage_name?: string | null;
location: TicketLocation | null; location: TicketLocation | null;
video_metadata: TicketVideoMetadata; video_metadata?: TicketVideoMetadata | null;
uploader: { uploader?: {
user_id: number; user_id: number;
name: string; name: string | null;
email: string; email: string | null;
}; } | null;
ai_result: { ai_result: {
detection_count: number; detection_count: number;
completed_at: string; completed_at: string;
available_defect_classes: string[]; available_defect_classes: string[];
detections_by_class: TicketDetectionClassCount[]; detections_by_class: TicketDetectionClassCount[];
}; };
timestamps: TicketTimestamps; timestamps?: TicketTimestamps | null;
} }
export interface TicketDetail { export interface TicketDetail {