Compare commits
4 Commits
c0f3a718ef
...
23186ffd29
| Author | SHA1 | Date | |
|---|---|---|---|
| 23186ffd29 | |||
| fb9a654aea | |||
| d88978a830 | |||
| ba9731de86 |
61
package-lock.json
generated
61
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,19 +103,19 @@ 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" />
|
||||||
|
<h2 className="text-base font-semibold">Detected Issues</h2>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="min-w-5 min-h-5 justify-center rounded-full"
|
className="min-w-5 min-h-5 justify-center rounded-full"
|
||||||
>
|
>
|
||||||
{defectClasses.length}
|
{defectClasses.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardTitle>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<TabsList className="flex h-auto w-full flex-col items-stretch gap-2 bg-transparent p-0">
|
<TabsList className="flex h-auto w-full flex-col items-stretch gap-2 bg-transparent p-0">
|
||||||
{defectClasses.map((item) => {
|
{defectClasses.map((item) => {
|
||||||
const statusConfig =
|
const statusConfig =
|
||||||
@@ -130,6 +146,16 @@ export function TicketDefectClassTabs({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</TabsList>
|
</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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{uploadedOn || uploaderName || locationLabel ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
|
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
|
||||||
|
{uploadedOn ? (
|
||||||
<div className="flex items-center gap-3 sm:pr-4">
|
<div className="flex items-center gap-3 sm:pr-4">
|
||||||
<CalendarDays className="size-5 shrink-0 text-muted-foreground" />
|
<CalendarDays className="size-5 shrink-0 text-muted-foreground" />
|
||||||
<OverviewField label="Uploaded On">
|
<OverviewField label="Uploaded On">
|
||||||
<MetaValue>{formatDate(ticket.timestamps.created_at)}</MetaValue>
|
<MetaValue>{formatDate(uploadedOn)}</MetaValue>
|
||||||
</OverviewField>
|
</OverviewField>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
{uploaderName ? (
|
||||||
<div className="flex items-center gap-3 sm:px-4">
|
<div className="flex items-center gap-3 sm:px-4">
|
||||||
<UserRound className="size-5 shrink-0 text-muted-foreground" />
|
<UserRound className="size-5 shrink-0 text-muted-foreground" />
|
||||||
<OverviewField label="Uploaded By">
|
<OverviewField label="Uploaded By">
|
||||||
<MetaValue>{ticket.uploader.name}</MetaValue>
|
<MetaValue>{uploaderName}</MetaValue>
|
||||||
</OverviewField>
|
</OverviewField>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
{locationLabel ? (
|
||||||
<div className="flex items-center gap-3 sm:pl-4">
|
<div className="flex items-center gap-3 sm:pl-4">
|
||||||
<MapPin className="size-5 shrink-0 text-muted-foreground" />
|
<MapPin className="size-5 shrink-0 text-muted-foreground" />
|
||||||
<OverviewField label="Location">
|
<OverviewField label="Location">
|
||||||
<MetaValue>{locationLabel}</MetaValue>
|
<MetaValue>{locationLabel}</MetaValue>
|
||||||
</OverviewField>
|
</OverviewField>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
|
{hasVideoMetrics ? (
|
||||||
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3">
|
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3">
|
||||||
<VideoMetric
|
{videoName ? (
|
||||||
icon={Gauge}
|
<VideoMetric icon={Monitor} label={videoName} />
|
||||||
label={`${ticket.video_metadata.fps} FPS`}
|
) : null}
|
||||||
/>
|
{videoMetadata?.fps ? (
|
||||||
|
<VideoMetric icon={Gauge} label={`${videoMetadata.fps} FPS`} />
|
||||||
|
) : null}
|
||||||
|
{duration ? (
|
||||||
<VideoMetric icon={Clock3} label={`${duration} Duration`} />
|
<VideoMetric icon={Clock3} label={`${duration} Duration`} />
|
||||||
|
) : null}
|
||||||
|
{videoMetadata?.resolution?.label ? (
|
||||||
<VideoMetric
|
<VideoMetric
|
||||||
icon={Monitor}
|
icon={Monitor}
|
||||||
label={`${ticket.video_metadata.resolution.label} Resolution`}
|
label={`${videoMetadata.resolution.label} Resolution`}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
) : null}
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<LayoutDashboard className="size-5 text-primary" />
|
|
||||||
Ticket Overview
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { RepairProofSummary as SubmittedRepairProofAction } from '../repair/RepairProofSummary';
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ? (
|
||||||
<>
|
|
||||||
<TicketRepairReportCard ticket={classDetail} />
|
|
||||||
<TicketHistoryCard 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} />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
66
src/components/annotation/boundingBoxOverlay.tsx
Normal file
66
src/components/annotation/boundingBoxOverlay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
src/components/map/detectionLocationMap.tsx
Normal file
142
src/components/map/detectionLocationMap.tsx
Normal 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="© 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
69
src/components/video/annotatedDetectionImage.tsx
Normal file
69
src/components/video/annotatedDetectionImage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user