2 Commits

19 changed files with 214 additions and 349 deletions

View File

@@ -1,27 +1,13 @@
'use client'; 'use client';
import { useMemo } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, TrendingUp } from 'lucide-react'; import { ArrowLeft, TrendingUp } from 'lucide-react';
import VideoPlayerSection from '@/components/videoPlayerSection'; import VideoPlayerSection from '@/components/videoPlayerSection';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { CompletedVideoResult, DetectionType } from '@/types';
import { getDetectionModeConfig } from '@/constants/detectionModeConfig';
import { useVideoResultsQuery } from '../hooks/useVideoResults'; import { useVideoResultsQuery } from '../hooks/useVideoResults';
import { VideoResultsSkeleton } from './components/VideoResultsSkeleton'; import { VideoResultsSkeleton } from './components/VideoResultsSkeleton';
const inferDetectionType = (data: CompletedVideoResult): DetectionType => {
if (data.detection_mode) return data.detection_mode as DetectionType;
const signboards = data.summary?.unique_signboards || 0;
const potholes = data.summary?.unique_potholes || 0;
if (signboards > 0 && potholes > 0) return 'pot-sign-detection';
if (signboards > 0) return 'sign-board-detection';
return 'pothole-detection';
};
export default function VideoResultsPage() { export default function VideoResultsPage() {
const router = useRouter(); const router = useRouter();
const { videoId } = useParams() as { videoId: string }; const { videoId } = useParams() as { videoId: string };
@@ -33,30 +19,19 @@ export default function VideoResultsPage() {
error: queryError, error: queryError,
} = useVideoResultsQuery(videoId); } = useVideoResultsQuery(videoId);
const detectionType = useMemo<DetectionType>(
() =>
detectionData ? inferDetectionType(detectionData) : 'pothole-detection',
[detectionData],
);
const error = isError const error = isError
? queryError instanceof Error ? queryError instanceof Error
? queryError.message ? queryError.message
: 'Failed to load results' : 'Failed to load results'
: null; : null;
const getTitle = () => {
const config = getDetectionModeConfig(detectionType);
return `${config.label} Results`;
};
if (isLoading) { if (isLoading) {
return <VideoResultsSkeleton />; return <VideoResultsSkeleton />;
} }
if (error) { if (error) {
return ( return (
<div className="min-h-[60vh] flex items-center justify-center"> <div className="flex min-h-[60vh] items-center justify-center">
<p className="text-sm text-destructive">{error}</p> <p className="text-sm text-destructive">{error}</p>
</div> </div>
); );
@@ -65,7 +40,7 @@ export default function VideoResultsPage() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<PageHeader <PageHeader
title={'Detection Results'} title="Detection Results"
description={`Video ID: ${videoId}`} description={`Video ID: ${videoId}`}
icon={TrendingUp} icon={TrendingUp}
actions={ actions={

View File

@@ -90,8 +90,8 @@ export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
</MetaValue> </MetaValue>
</OverviewField> </OverviewField>
<OverviewField label="Chainage"> <OverviewField label="Chainage" >
<MetaValue>{ticket.chainage_id || '-'}</MetaValue> <MetaValue>{ticket.chainage_name}</MetaValue>
</OverviewField> </OverviewField>
<OverviewField label="Uploaded By"> <OverviewField label="Uploaded By">

View File

@@ -88,7 +88,6 @@ function buildTicketListItem(
!event.id || !event.id ||
!event.ticket_name || !event.ticket_name ||
!event.status || !event.status ||
!event.default_defect_class ||
event.detection_count === undefined || event.detection_count === undefined ||
!event.updated_at !event.updated_at
) { ) {
@@ -101,7 +100,7 @@ function buildTicketListItem(
chainage_id: event.chainage_id ?? null, chainage_id: event.chainage_id ?? null,
chainage_name: event.chainage_name ?? null, chainage_name: event.chainage_name ?? null,
status: event.status, status: event.status,
default_defect_class: event.default_defect_class, default_defect_class: event.default_defect_class ?? null,
assigned_to_name: event.assigned_to_name ?? null, assigned_to_name: event.assigned_to_name ?? null,
detection_count: event.detection_count, detection_count: event.detection_count,
created_by_name: event.created_by_name ?? null, created_by_name: event.created_by_name ?? null,

View File

@@ -63,8 +63,8 @@ export function useTicketDetailQuery(
return useQuery({ return useQuery({
queryKey: ticketKeys.detail(ticketId ?? '', defectClass), queryKey: ticketKeys.detail(ticketId ?? '', defectClass),
queryFn: () => queryFn: () =>
ticketService.getTicketDetail(ticketId as string, defectClass as string), ticketService.getTicketDetail(ticketId as string, defectClass),
enabled: Boolean(ticketId && defectClass), enabled: Boolean(ticketId),
}); });
} }

View File

@@ -44,9 +44,11 @@ export default function TicketPage() {
const handleView = useCallback( const handleView = useCallback(
(ticket: TicketListItem) => { (ticket: TicketListItem) => {
router.push( const defectClassQuery = ticket.default_defect_class
`/ticket/${ticket.id}?defect_class=${encodeURIComponent(ticket.default_defect_class)}`, ? `?defect_class=${encodeURIComponent(ticket.default_defect_class)}`
); : '';
router.push(`/ticket/${ticket.id}${defectClassQuery}`);
}, },
[router], [router],
); );

View File

@@ -1,48 +1,36 @@
'use client'; 'use client';
import { Activity, AlertTriangle, MapPin, SignpostBig } from 'lucide-react'; import { Activity, MapPin } from 'lucide-react';
import type { CompletedVideoResult, DetectionResultLog } from '@/types'; import type { CompletedVideoResult, DetectionResultLog } from '@/types';
import { getDefectVisual } from './defectVisualConfig';
interface CurrentDetectionBarProps { interface CurrentDetectionBarProps {
activeLog?: DetectionResultLog; activeLog?: DetectionResultLog;
summary: CompletedVideoResult['summary']; data: CompletedVideoResult;
} }
export default function CurrentDetectionBar({ export default function CurrentDetectionBar({
activeLog, activeLog,
summary, data,
}: CurrentDetectionBarProps) { }: CurrentDetectionBarProps) {
const currentCounts = activeLog?.cumulative_counts || summary; const defectCounts = activeLog
const countItems = [ ? activeLog.running_counts.by_class
{ : data.defect_counts;
label: 'Potholes', const totalDetections = activeLog
value: currentCounts.unique_potholes || 0, ? activeLog.running_counts.total
icon: AlertTriangle, : data.summary.total_detections;
className: 'text-orange-500', const { latitude, longitude } = activeLog?.location ?? {
iconClassName: 'bg-orange-500/10 text-orange-500', latitude: undefined,
}, longitude: undefined,
{ };
label: 'Signboards',
value: currentCounts.unique_signboards || 0,
icon: SignpostBig,
className: 'text-blue-500',
iconClassName: 'bg-blue-500/10 text-blue-500',
},
{
label: 'Total',
value: currentCounts.total_detections || 0,
icon: Activity,
className: 'text-green-500',
iconClassName: 'bg-green-500/10 text-green-500',
},
];
const latitude = activeLog?.latitude;
const longitude = activeLog?.longitude;
const coordinates = const coordinates =
typeof latitude === 'number' && typeof longitude === 'number' typeof latitude === 'number' && typeof longitude === 'number'
? `${latitude.toFixed(7)}, ${longitude.toFixed(7)}` ? `${latitude.toFixed(7)}, ${longitude.toFixed(7)}`
: undefined; : undefined;
const gridColumns =
defectCounts.length >= 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2';
return ( return (
<section className="overflow-hidden rounded-lg border bg-card/80"> <section className="overflow-hidden rounded-lg border bg-card/80">
@@ -67,33 +55,48 @@ export default function CurrentDetectionBar({
) : null} ) : null}
</div> </div>
<div className="grid grid-cols-1 gap-2 p-3 sm:grid-cols-3"> <div className={`grid grid-cols-1 gap-2 p-3 ${gridColumns}`}>
{countItems.map((item) => { {defectCounts.map((item) => {
const Icon = item.icon; const visual = getDefectVisual(item.class_name);
const Icon = visual.icon;
return ( return (
<div <div
key={item.label} key={item.class_name}
className="flex items-center justify-between gap-3 rounded-md bg-muted/25 px-3 py-2.5 ring-1 ring-border/60" className="flex items-center justify-between gap-3 rounded-md bg-muted/25 px-3 py-2.5 ring-1 ring-border/60"
> >
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<span <span
className={`flex size-8 shrink-0 items-center justify-center rounded-md ${item.iconClassName}`} className={`flex size-8 shrink-0 items-center justify-center rounded-md ${visual.iconClassName}`}
> >
<Icon className="size-4" /> <Icon className="size-4" />
</span> </span>
<p className="truncate text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="truncate text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{item.label} {item.display_name}
</p> </p>
</div> </div>
<p <p
className={`text-2xl font-bold leading-none ${item.className}`} className={`text-2xl font-bold leading-none ${visual.colorClassName}`}
> >
{item.value} {item.count}
</p> </p>
</div> </div>
); );
})} })}
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/25 px-3 py-2.5 ring-1 ring-border/60">
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-green-500/10 text-green-500">
<Activity className="size-4" />
</span>
<p className="truncate text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Total
</p>
</div>
<p className="text-2xl font-bold leading-none text-green-500">
{totalDetections}
</p>
</div>
</div> </div>
</section> </section>
); );

View File

@@ -0,0 +1,54 @@
'use client';
import {
Activity,
AlertTriangle,
CircleAlert,
CircleDot,
Droplets,
LucideIcon,
SignpostBig,
} from 'lucide-react';
type DefectVisualConfig = {
icon: LucideIcon;
colorClassName: string;
iconClassName: string;
};
const DEFECT_VISUALS: Record<string, DefectVisualConfig> = {
manhole_cover: {
icon: CircleDot,
colorClassName: 'text-zinc-500',
iconClassName: 'bg-zinc-500/10 text-zinc-500',
},
pothole: {
icon: AlertTriangle,
colorClassName: 'text-orange-500',
iconClassName: 'bg-orange-500/10 text-orange-500',
},
road_crack: {
icon: CircleAlert,
colorClassName: 'text-rose-500',
iconClassName: 'bg-rose-500/10 text-rose-500',
},
sign_board: {
icon: SignpostBig,
colorClassName: 'text-blue-500',
iconClassName: 'bg-blue-500/10 text-blue-500',
},
water_puddle: {
icon: Droplets,
colorClassName: 'text-cyan-500',
iconClassName: 'bg-cyan-500/10 text-cyan-500',
},
};
const DEFAULT_VISUAL: DefectVisualConfig = {
icon: Activity,
colorClassName: 'text-green-500',
iconClassName: 'bg-green-500/10 text-green-500',
};
export const getDefectVisual = (className: string) =>
DEFECT_VISUALS[className] || DEFAULT_VISUAL;

View File

@@ -2,10 +2,8 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { Activity, Film, MapPin } from 'lucide-react'; import { Activity, Film, MapPin } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { DetectionResultLog } from '@/types'; import { DetectionResultLog } from '@/types';
import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface DetectionLogsProps { interface DetectionLogsProps {
@@ -34,7 +32,7 @@ const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-sm font-bold flex items-center gap-2"> <h4 className="flex items-center gap-2 text-sm font-bold">
<Activity className="h-4 w-4" /> <Activity className="h-4 w-4" />
Detection Logs Detection Logs
</h4> </h4>
@@ -43,20 +41,18 @@ const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => {
<div className="space-y-3 p-4"> <div className="space-y-3 p-4">
{logs.length === 0 ? ( {logs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Film className="h-8 w-8 mb-2 opacity-20" /> <Film className="mb-2 h-8 w-8 opacity-20" />
<p className="text-xs">No detections found</p> <p className="text-xs">No detections found</p>
</div> </div>
) : ( ) : (
logs.map((log) => { logs.map((log) => {
const typeId = (log.type || '').toLowerCase(); const timestampSeconds = log.frame.timestamp_seconds;
const typeConfig = DETECTION_TYPES[typeId]; const { latitude, longitude } = log.location;
const label =
log.label || typeConfig?.label || typeId.replace(/_/g, ' ');
const isActive = activeLogId === log.id; const isActive = activeLogId === log.id;
return ( return (
<button <button
key={`${log.id}-${log.frame}`} key={`${log.id}-${log.frame.number}`}
ref={isActive ? activeLogRef : null} ref={isActive ? activeLogRef : null}
type="button" type="button"
onClick={() => onSeek(log)} onClick={() => onSeek(log)}
@@ -65,35 +61,35 @@ const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => {
isActive && 'border-primary bg-primary/5', isActive && 'border-primary bg-primary/5',
)} )}
> >
<div className="flex items-start justify-between gap-4 mb-3"> <div className="mb-3 flex items-start justify-between gap-4">
<div> <div>
<div className="text-[13px] font-bold"> <div className="text-[13px] font-bold">
{label} ID: {log.id} {log.detection.display_name} ID: {log.id}
</div> </div>
<div className="text-[11px] text-muted-foreground font-medium"> <div className="text-[11px] font-medium text-muted-foreground">
Frame {log.frame} Frame {log.frame.number}
</div> </div>
</div> </div>
<div className="text-right shrink-0"> <div className="shrink-0 text-right">
<div className="text-xs font-bold"> <div className="text-xs font-bold">
{formatVideoTime(log.timestamp_seconds)} {formatVideoTime(timestampSeconds)}
</div> </div>
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
{log.timestamp_seconds.toFixed(2)}s {timestampSeconds.toFixed(2)}s
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-2 text-[12px] text-muted-foreground/90 font-medium"> <div className="space-y-2 text-[12px] font-medium text-muted-foreground/90">
{typeof log.confidence === 'number' && ( <div>
<div>Confidence: {(log.confidence * 100).toFixed(1)}%</div> Confidence: {(log.detection.confidence * 100).toFixed(1)}%
)} </div>
{typeof log.latitude === 'number' && {typeof latitude === 'number' &&
typeof log.longitude === 'number' && ( typeof longitude === 'number' && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<MapPin className="h-3 w-3" /> <MapPin className="h-3 w-3" />
<span> <span>
{log.latitude.toFixed(8)}, {log.longitude.toFixed(8)} {latitude.toFixed(8)}, {longitude.toFixed(8)}
</span> </span>
</div> </div>
)} )}

View File

@@ -1,81 +1,80 @@
'use client'; 'use client';
import { import { Activity, Clock, Gauge } from 'lucide-react';
Activity,
AlertTriangle,
Clock,
Gauge,
SignpostBig,
} from 'lucide-react';
import { CompletedVideoResult } from '@/types'; import { CompletedVideoResult } from '@/types';
const formatDuration = (seconds: number) => { import { getDefectVisual } from './defectVisualConfig';
const safeSeconds = Number.isFinite(seconds) ? seconds : 0;
const minutes = Math.floor(safeSeconds / 60); const formatDuration = (seconds?: number) => {
const secs = Math.floor(safeSeconds % 60); if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return '-';
const minutes = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${minutes}:${secs.toString().padStart(2, '0')}`; return `${minutes}:${secs.toString().padStart(2, '0')}`;
}; };
const formatFps = (fps?: number) =>
typeof fps === 'number' && Number.isFinite(fps) ? fps.toFixed(1) : '-';
interface ResultStatsGridProps { interface ResultStatsGridProps {
data: CompletedVideoResult; data: CompletedVideoResult;
} }
export default function ResultStatsGrid({ data }: ResultStatsGridProps) { export default function ResultStatsGrid({ data }: ResultStatsGridProps) {
const defectStats = data.defect_counts.map((item) => {
const visual = getDefectVisual(item.class_name);
return {
label: item.display_name,
value: item.count,
icon: visual.icon,
color: visual.colorClassName,
bgColor: visual.iconClassName,
};
});
const stats = [ const stats = [
{ {
label: 'Total Detections', label: 'Total Detections',
value: data.summary.total_detections || 0, value: data.summary.total_detections,
icon: Activity, icon: Activity,
color: 'text-green-500', color: 'text-green-500',
bgColor: 'bg-green-500/10', bgColor: 'bg-green-500/10 text-green-500',
},
{
label: 'Potholes',
value: data.summary.unique_potholes || 0,
icon: AlertTriangle,
color: 'text-orange-500',
bgColor: 'bg-orange-500/10',
},
{
label: 'Signboards',
value: data.summary.unique_signboards || 0,
icon: SignpostBig,
color: 'text-blue-500',
bgColor: 'bg-blue-500/10',
}, },
...defectStats,
{ {
label: 'FPS', label: 'FPS',
value: data.fps.toFixed(1), value: formatFps(data.summary.fps),
icon: Gauge, icon: Gauge,
color: 'text-purple-500', color: 'text-purple-500',
bgColor: 'bg-purple-500/10', bgColor: 'bg-purple-500/10 text-purple-500',
}, },
{ {
label: 'Duration', label: 'Duration',
value: formatDuration(data.duration_seconds), value: formatDuration(data.summary.duration_seconds),
icon: Clock, icon: Clock,
color: 'text-cyan-500', color: 'text-cyan-500',
bgColor: 'bg-cyan-500/10', bgColor: 'bg-cyan-500/10 text-cyan-500',
}, },
]; ];
return ( return (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3"> <div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-5">
{stats.map((stat) => { {stats.map((stat) => {
const Icon = stat.icon; const Icon = stat.icon;
return ( return (
<div <div
key={stat.label} key={stat.label}
className="p-4 rounded-lg bg-muted/30 border border-muted flex flex-col items-center text-center" className="flex flex-col items-center rounded-lg border border-muted bg-muted/30 p-4 text-center"
> >
<div className={`p-2 rounded-md mb-2 ${stat.bgColor}`}> <div className={`mb-2 rounded-md p-2 ${stat.bgColor}`}>
<Icon className={`h-5 w-5 ${stat.color}`} /> <Icon className="h-5 w-5" />
</div> </div>
<div className={`text-xl font-bold ${stat.color}`}> <div className={`text-xl font-bold ${stat.color}`}>
{stat.value} {stat.value}
</div> </div>
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mt-1"> <div className="mt-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
{stat.label} {stat.label}
</div> </div>
</div> </div>

View File

@@ -14,7 +14,10 @@ export function useDetectionPlayback({
}: UseDetectionPlaybackParams) { }: UseDetectionPlaybackParams) {
const shouldPauseAfterSeekRef = useRef(false); const shouldPauseAfterSeekRef = useRef(false);
const sortedLogs = useMemo( const sortedLogs = useMemo(
() => [...(logs || [])].sort((a, b) => a.timestamp_seconds - b.timestamp_seconds), () =>
[...(logs || [])].sort(
(a, b) => a.frame.timestamp_seconds - b.frame.timestamp_seconds,
),
[logs], [logs],
); );
const [activeLog, setActiveLog] = useState<DetectionResultLog | undefined>( const [activeLog, setActiveLog] = useState<DetectionResultLog | undefined>(
@@ -27,7 +30,7 @@ export function useDetectionPlayback({
shouldPauseAfterSeekRef.current = true; shouldPauseAfterSeekRef.current = true;
video.pause(); video.pause();
video.currentTime = log.timestamp_seconds; video.currentTime = log.frame.timestamp_seconds;
setActiveLog(log); setActiveLog(log);
}; };
@@ -44,7 +47,7 @@ export function useDetectionPlayback({
if (!video || sortedLogs.length === 0) return; if (!video || sortedLogs.length === 0) return;
const currentLog = sortedLogs.findLast( const currentLog = sortedLogs.findLast(
(log) => log.timestamp_seconds <= video.currentTime, (log) => log.frame.timestamp_seconds <= video.currentTime,
); );
if (currentLog && currentLog.id !== activeLog?.id) { if (currentLog && currentLog.id !== activeLog?.id) {

View File

@@ -21,9 +21,7 @@ type VideoPlayerSectionProps = {
data: CompletedVideoResult; data: CompletedVideoResult;
}; };
export default function VideoPlayerSection({ export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
data,
}: VideoPlayerSectionProps) {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const { const {
activeLog, activeLog,
@@ -45,9 +43,9 @@ export default function VideoPlayerSection({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<CardHeader className="pb-4 border-b"> <CardHeader className="border-b pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 rounded bg-secondary"> <div className="rounded bg-secondary p-2">
<Film className="h-5 w-5 text-primary" /> <Film className="h-5 w-5 text-primary" />
</div> </div>
<div> <div>
@@ -61,8 +59,8 @@ export default function VideoPlayerSection({
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6"> <div className="space-y-6 lg:col-span-2">
<AnnotatedVideoPlayer <AnnotatedVideoPlayer
videoRef={videoRef} videoRef={videoRef}
videoUrl={videoUrl} videoUrl={videoUrl}
@@ -73,10 +71,7 @@ export default function VideoPlayerSection({
onSeeked={handleSeeked} onSeeked={handleSeeked}
/> />
<CurrentDetectionBar <CurrentDetectionBar activeLog={activeLog} data={data} />
activeLog={activeLog}
summary={data.summary}
/>
</div> </div>
<DetectionLogs <DetectionLogs
@@ -91,7 +86,6 @@ export default function VideoPlayerSection({
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }

View File

@@ -54,7 +54,8 @@ export const API_ROUTES = {
}, },
TICKETS: { TICKETS: {
BASE: '/biz/api/v1/tickets', BASE: '/biz/api/v1/tickets',
DETAIL: (id: string, defectClass: string) => DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
CLASS_DETAIL: (id: string, defectClass: string) =>
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`, `/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`, DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`, ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,

View File

@@ -1,149 +0,0 @@
/**
* Detection mode configurations to support dynamic UI behavior
*/
export interface DetectionTypeConfig {
id: string;
label: string;
color: string;
}
export interface DetectionModeConfig {
id: string;
label: string;
enabledTypes: string[]; // IDs of detection types to show in this mode
}
export const DETECTION_TYPES: Record<string, DetectionTypeConfig> = {
pothole: {
id: 'pothole',
label: 'Pothole',
color: 'var(--chart-1)',
},
defected_sign_board: {
id: 'defected_sign_board',
label: 'Defect Sign Board',
color: 'var(--chart-2)',
},
road_crack: {
id: 'road_crack',
label: 'Road Crack',
color: 'var(--chart-3)',
},
damaged_road_marking: {
id: 'damaged_road_marking',
label: 'Damage Road Mark',
color: 'var(--chart-4)',
},
good_sign_board: {
id: 'good_sign_board',
label: 'Good Sign Board',
color: 'var(--chart-5)',
},
drain_issue: {
id: 'drain_issue',
label: 'Drain Issue',
color: 'var(--chart-6)',
},
good_culvert: {
id: 'good_culvert',
label: 'Good Culvert',
color: 'var(--chart-5)',
},
defective_culvert: {
id: 'defective_culvert',
label: 'Defective Culvert',
color: 'var(--chart-8)',
},
};
export const DETECTION_MODES: Record<string, DetectionModeConfig> = {
yolo: {
id: 'yolo',
label: 'YOLO Detection',
enabledTypes: [
'pothole',
'defected_sign_board',
'road_crack',
'damaged_road_marking',
'good_sign_board',
'drain_issue',
],
},
yolo_vl: {
id: 'yolo_vl',
label: 'YOLO-VL Detection',
enabledTypes: [
'pothole',
'defected_sign_board',
'road_crack',
'damaged_road_marking',
'good_sign_board',
'drain_issue',
],
},
'pothole-detection': {
id: 'pothole-detection',
label: 'Pothole Detection',
enabledTypes: [
'pothole',
'road_crack',
'damaged_road_marking',
'drain_issue',
],
},
'sign-board-detection': {
id: 'sign-board-detection',
label: 'Signboard Detection',
enabledTypes: ['defected_sign_board', 'good_sign_board'],
},
'pot-sign-detection': {
id: 'pot-sign-detection',
label: 'Pothole & Signboard Detection',
enabledTypes: [
'pothole',
'defected_sign_board',
'road_crack',
'damaged_road_marking',
'good_sign_board',
'drain_issue',
],
},
culvert_detection: {
id: 'culvert_detection',
label: 'Culvert Detection',
enabledTypes: ['good_culvert', 'defective_culvert'],
},
gemini_video: {
id: 'gemini_video',
label: 'Gemini AI Analysis',
enabledTypes: [
'pothole',
'defected_sign_board',
'road_crack',
'damaged_road_marking',
'good_sign_board',
'drain_issue',
'good_culvert',
'defective_culvert',
],
},
};
// Fallback config for unknown modes
export const DEFAULT_DETECTION_MODE: DetectionModeConfig =
DETECTION_MODES['pot-sign-detection'];
export const getDetectionModeConfig = (mode?: string): DetectionModeConfig => {
if (!mode) return DEFAULT_DETECTION_MODE;
return DETECTION_MODES[mode] || DEFAULT_DETECTION_MODE;
};
export const getEnabledDetectionTypes = (
mode?: string,
): DetectionTypeConfig[] => {
const config = getDetectionModeConfig(mode);
return config.enabledTypes
.map((typeId) => DETECTION_TYPES[typeId])
.filter(Boolean);
};

View File

@@ -35,11 +35,13 @@ export const ticketService = {
getTicketDetail: async ( getTicketDetail: async (
ticketId: string, ticketId: string,
defectClass: string, defectClass?: string,
): Promise<TicketDetail> => { ): Promise<TicketDetail> => {
const response = await axiosClient.get<TicketDetail>( const path = defectClass
API_ROUTES.TICKETS.DETAIL(ticketId, defectClass), ? API_ROUTES.TICKETS.CLASS_DETAIL(ticketId, defectClass)
); : API_ROUTES.TICKETS.DETAIL(ticketId);
const response = await axiosClient.get<TicketDetail>(path);
return response.data; return response.data;
}, },

View File

@@ -1,19 +1,15 @@
import { DetectionResultLog } from './detection'; import { DetectionClassCount, DetectionResultLog } from './detection';
export type CompletedVideoResult = { export type CompletedVideoResult = {
video_id: string; video_id: string;
status: 'completed' | string; status: 'completed' | string;
// annotated_video_url: string;
processed_video_url: string | null; processed_video_url: string | null;
raw_video_url: string | null; raw_video_url: string | null;
fps: number;
duration_seconds: number;
detection_mode?: string;
summary: { summary: {
fps?: number;
duration_seconds?: number;
total_detections: number; total_detections: number;
unique_potholes?: number;
unique_signboards?: number;
[key: string]: number | undefined;
}; };
defect_counts: DetectionClassCount[];
logs: DetectionResultLog[]; logs: DetectionResultLog[];
}; };

View File

@@ -1,43 +1,32 @@
/** export type DetectionClassCount = {
* Detection types for map and display class_name: string;
*/ display_name: string;
export interface Detection { count: number;
id: number; unique_count: number;
video_id: string; };
type: string;
class: string;
confidence: number;
latitude: number | null;
longitude: number | null;
frame_number: number;
timestamp_ms: number;
}
export type DetectionType =
| 'pothole-detection'
| 'sign-board-detection'
| 'pot-sign-detection'
| 'culvert_detection'
| 'yolo'
| 'yolo_vl'
| 'sam3'
| 'yoloe'
| 'yoloe_trained_vl'
| 'gemini_video';
export type DetectionResultLog = { export type DetectionResultLog = {
id: string; id: string;
type: string; detection: {
label?: string; id: number;
frame: number; class_name: string;
timestamp_seconds: number; display_name: string;
confidence?: number; confidence: number;
latitude?: number | null; };
longitude?: number | null; frame: {
cumulative_counts?: { number: number;
unique_potholes?: number; timestamp_seconds: number;
unique_signboards?: number; };
total_detections?: number; location: {
[key: string]: number | undefined; latitude: number | null;
longitude: number | null;
};
running_counts: {
total: number;
by_class: Array<{
class_name: string;
display_name: string;
count: number;
}>;
}; };
}; };

View File

@@ -71,6 +71,7 @@ export interface TicketDetail {
ticket_name?: string; ticket_name?: string;
video_id: string; video_id: string;
chainage_id: string | null; chainage_id: string | null;
chainage_name: string | null;
status: TicketStatus; status: TicketStatus;
defect_class: string; defect_class: string;
defect_display_name: string; defect_display_name: string;

View File

@@ -14,7 +14,7 @@ export type TicketTableStatusEvent = {
chainage_id?: string | null; chainage_id?: string | null;
chainage_name?: string | null; chainage_name?: string | null;
status?: TicketStatus; status?: TicketStatus;
default_defect_class?: string; default_defect_class?: string | null;
assigned_to_name?: string | null; assigned_to_name?: string | null;
detection_count?: number; detection_count?: number;
created_by_name?: string | null; created_by_name?: string | null;

View File

@@ -12,7 +12,7 @@ export interface TicketListItem {
chainage_id: string | null; chainage_id: string | null;
chainage_name: string | null; chainage_name: string | null;
status: TicketStatus; status: TicketStatus;
default_defect_class: string; default_defect_class: string | null;
assigned_to_name: string | null; assigned_to_name: string | null;
detection_count: number; detection_count: number;
created_by_name: string | null; created_by_name: string | null;