feat(ticket): redesign overview with AI summary, location, and shared defect visuals

This commit is contained in:
2026-07-03 10:30:59 +05:30
parent 02adbb4c10
commit 9cf4f0e795
7 changed files with 309 additions and 75 deletions

View File

@@ -4,9 +4,6 @@ import { ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { SparkleButton } from '@/components/ui/sparkle-button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { TicketOverviewDetail } from '@/types';
export function TicketDetailHeader({
@@ -25,17 +22,6 @@ export function TicketDetailHeader({
</div>
<div className="flex shrink-0 items-center gap-2">
{ticket.video_id ? (
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<SparkleButton
className="h-10 min-w-36 px-5"
onClick={() => router.push(`/results/${ticket.video_id}`)}
>
View Analysis
</SparkleButton>
</PermissionGuard>
) : null}
<Button
variant="outline"
size="sm"

View File

@@ -1,11 +1,34 @@
'use client';
import { LayoutDashboard } from 'lucide-react';
import {
Activity,
ArrowRight,
CalendarDays,
Clock3,
Gauge,
LayoutDashboard,
MapPin,
Monitor,
UserRound,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PersonInfo } from '@/components/person-avatar';
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import { cn } from '@/lib/utils';
import type { TicketOverviewDetail } from '@/types';
import { formatDateOnly } from '@/utils/date';
import { formatDate } from '@/utils/date';
type IconComponent = React.ComponentType<{ className?: string }>;
function MetaLabel({ children }: { children: React.ReactNode }) {
return (
@@ -27,46 +50,221 @@ function OverviewField({
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<div className="min-w-0 space-y-1">
<MetaLabel>{label}</MetaLabel>
{children}
</div>
);
}
export function TicketOverviewCard({
ticket,
function SummaryCard({
label,
value,
colorClassName,
cardClassName,
icon: Icon,
}: {
ticket: TicketOverviewDetail;
label: string;
value: number;
colorClassName: string;
cardClassName: string;
icon?: IconComponent;
}) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LayoutDashboard className="size-5 text-primary" />
Overview
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-4">
<OverviewField label="Detection Count">
<MetaValue>{ticket.ai_result.detection_count} Occurrences</MetaValue>
</OverviewField>
<OverviewField label="Chainage">
<MetaValue>{ticket.chainage_name}</MetaValue>
</OverviewField>
<OverviewField label="Uploaded By">
<PersonInfo person={ticket.uploader} />
</OverviewField>
<OverviewField label="Created Date">
<MetaValue>
{formatDateOnly(ticket.timestamps.created_at)}
</MetaValue>
</OverviewField>
</CardContent>
</Card>
<div
className={cn(
'rounded-xl bg-muted/40 p-4 transition-colors hover:bg-muted/60',
cardClassName,
)}
>
<p className={cn('text-2xl font-bold tracking-tight', colorClassName)}>
{value}
</p>
<p className="mt-2 truncate text-xs font-semibold text-foreground">
{label}
</p>
<p className="mt-0.5 text-[11px] text-muted-foreground">Detected</p>
</div>
);
}
function VideoMetric({
icon: Icon,
label,
}: {
icon: IconComponent;
label: string;
}) {
return (
<div className="flex items-center justify-center gap-2 font-medium">
<Icon className="size-4 text-muted-foreground" />
<span>{label}</span>
</div>
);
}
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({
ticket,
defectClass,
}: {
ticket: TicketOverviewDetail;
defectClass?: string;
}) {
const router = useRouter();
const currentIssue =
ticket.ai_result.detections_by_class.find(
(item) => item.class_name === defectClass,
) ?? ticket.ai_result.detections_by_class[0];
const currentVisual = getDefectVisual(currentIssue?.class_name ?? '');
const minutes = Math.floor(ticket.video_metadata.duration_seconds / 60);
const seconds = Math.floor(ticket.video_metadata.duration_seconds % 60);
const duration = `${minutes}:${seconds.toString().padStart(2, '0')}`;
const chainageName =
ticket.location.chainage.name || ticket.chainage_name || null;
const packageName = ticket.location.package.name || null;
const locationLabel =
[chainageName, packageName].filter(Boolean).join(', ') || '—';
return (
<div className="space-y-5">
<Card>
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
<div className="flex items-center gap-3 sm:pr-4">
<CalendarDays className="size-5 shrink-0 text-muted-foreground" />
<OverviewField label="Uploaded On">
<MetaValue>{formatDate(ticket.timestamps.created_at)}</MetaValue>
</OverviewField>
</div>
<div className="flex items-center gap-3 sm:px-4">
<UserRound className="size-5 shrink-0 text-muted-foreground" />
<OverviewField label="Uploaded By">
<MetaValue>{ticket.uploader.name}</MetaValue>
</OverviewField>
</div>
<div className="flex items-center gap-3 sm:pl-4">
<MapPin className="size-5 shrink-0 text-muted-foreground" />
<OverviewField label="Location">
<MetaValue>{locationLabel}</MetaValue>
</OverviewField>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="size-5 text-primary" />
AI Detection Summary
</CardTitle>
{ticket.video_id ? (
<CardAction>
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<Button
variant="ghost"
size="sm"
className="text-primary hover:bg-primary/10 hover:text-primary"
onClick={() => router.push(`/results/${ticket.video_id}`)}
>
View Analysis
<ArrowRight className="size-3.5" />
</Button>
</PermissionGuard>
</CardAction>
) : null}
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
<SummaryCard
label="Total Detections"
value={ticket.ai_result.detection_count}
colorClassName="text-violet-600"
cardClassName="bg-violet-500/8"
/>
{ticket.ai_result.detections_by_class.map((item) => {
const visual = getDefectVisual(item.class_name);
return (
<SummaryCard
key={item.class_name}
label={item.display_name}
value={item.count}
icon={visual.icon}
colorClassName={visual.colorClassName}
cardClassName={visual.cardClassName}
/>
);
})}
</div>
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3">
<VideoMetric
icon={Gauge}
label={`${ticket.video_metadata.fps} FPS`}
/>
<VideoMetric icon={Clock3} label={`${duration} Duration`} />
<VideoMetric
icon={Monitor}
label={`${ticket.video_metadata.resolution.label} Resolution`}
/>
</div>
</CardContent>
</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>
</Card>
</div>
);
}

View File

@@ -69,7 +69,7 @@ export default function TicketDetailPage() {
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]">
<div className="space-y-5">
{overview ? (
<TicketOverviewCard ticket={overview} />
<TicketOverviewCard ticket={overview} defectClass={defectClass} />
) : (
<TicketOverviewCardSkeleton />
)}

View File

@@ -4,7 +4,7 @@ import { Activity, MapPin } from 'lucide-react';
import type { CompletedVideoResult, DetectionResultLog } from '@/types';
import { getDefectVisual } from './defectVisualConfig';
import { getDefectVisual } from '@/constants/defectVisualConfig';
interface CurrentDetectionBarProps {
activeLog?: DetectionResultLog;

View File

@@ -1,9 +1,10 @@
'use client';
import { Activity, Clock, Gauge } from 'lucide-react';
import { CompletedVideoResult } from '@/types';
import { getDefectVisual } from './defectVisualConfig';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import { cn } from '@/lib/utils';
import type { CompletedVideoResult } from '@/types';
const formatDuration = (seconds?: number) => {
if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return '-';
@@ -28,8 +29,8 @@ export default function ResultStatsGrid({ data }: ResultStatsGridProps) {
label: item.display_name,
value: item.count,
icon: visual.icon,
color: visual.colorClassName,
bgColor: visual.iconClassName,
colorClassName: visual.colorClassName,
iconClassName: visual.iconClassName,
};
});
@@ -38,23 +39,23 @@ export default function ResultStatsGrid({ data }: ResultStatsGridProps) {
label: 'Total Detections',
value: data.summary.total_detections,
icon: Activity,
color: 'text-green-500',
bgColor: 'bg-green-500/10 text-green-500',
colorClassName: 'text-green-500',
iconClassName: 'bg-green-500/10 text-green-500',
},
...defectStats,
{
label: 'FPS',
value: formatFps(data.summary.fps),
icon: Gauge,
color: 'text-purple-500',
bgColor: 'bg-purple-500/10 text-purple-500',
colorClassName: 'text-purple-500',
iconClassName: 'bg-purple-500/10 text-purple-500',
},
{
label: 'Duration',
value: formatDuration(data.summary.duration_seconds),
icon: Clock,
color: 'text-cyan-500',
bgColor: 'bg-cyan-500/10 text-cyan-500',
colorClassName: 'text-cyan-500',
iconClassName: 'bg-cyan-500/10 text-cyan-500',
},
];
@@ -66,17 +67,29 @@ export default function ResultStatsGrid({ data }: ResultStatsGridProps) {
return (
<div
key={stat.label}
className="flex flex-col items-center rounded-lg border border-muted bg-muted/30 p-4 text-center"
className="rounded-xl bg-muted/40 p-4 transition-colors hover:bg-muted/60"
>
<div className={`mb-2 rounded-md p-2 ${stat.bgColor}`}>
<Icon className="h-5 w-5" />
<div className="flex items-center justify-between gap-3">
<p
className={cn(
'text-2xl font-bold tracking-tight',
stat.colorClassName,
)}
>
{stat.value}
</p>
<span
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-lg',
stat.iconClassName,
)}
>
<Icon className="size-3.5" />
</span>
</div>
<div className={`text-xl font-bold ${stat.color}`}>
{stat.value}
</div>
<div className="mt-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<p className="mt-2 truncate text-xs font-semibold text-foreground">
{stat.label}
</div>
</p>
</div>
);
})}

View File

@@ -2,11 +2,11 @@
import {
Activity,
AlertTriangle,
CircleAlert,
Construction,
Spline,
CircleDot,
Droplets,
LucideIcon,
type LucideIcon,
SignpostBig,
} from 'lucide-react';
@@ -14,6 +14,7 @@ type DefectVisualConfig = {
icon: LucideIcon;
colorClassName: string;
iconClassName: string;
cardClassName: string;
};
const DEFECT_VISUALS: Record<string, DefectVisualConfig> = {
@@ -21,26 +22,31 @@ const DEFECT_VISUALS: Record<string, DefectVisualConfig> = {
icon: CircleDot,
colorClassName: 'text-zinc-500',
iconClassName: 'bg-zinc-500/10 text-zinc-500',
cardClassName: 'bg-zinc-500/5',
},
pothole: {
icon: AlertTriangle,
icon: Construction,
colorClassName: 'text-orange-500',
iconClassName: 'bg-orange-500/10 text-orange-500',
cardClassName: 'bg-orange-500/5',
},
road_crack: {
icon: CircleAlert,
icon: Spline,
colorClassName: 'text-rose-500',
iconClassName: 'bg-rose-500/10 text-rose-500',
cardClassName: 'bg-rose-500/5',
},
sign_board: {
icon: SignpostBig,
colorClassName: 'text-blue-500',
iconClassName: 'bg-blue-500/10 text-blue-500',
cardClassName: 'bg-blue-500/5',
},
water_puddle: {
icon: Droplets,
colorClassName: 'text-cyan-500',
iconClassName: 'bg-cyan-500/10 text-cyan-500',
cardClassName: 'bg-cyan-500/5',
},
};
@@ -48,6 +54,7 @@ const DEFAULT_VISUAL: DefectVisualConfig = {
icon: Activity,
colorClassName: 'text-green-500',
iconClassName: 'bg-green-500/10 text-green-500',
cardClassName: 'bg-green-500/5',
};
export const getDefectVisual = (className: string) =>

View File

@@ -80,12 +80,41 @@ export interface TicketTimestamps {
updated_at: string;
}
export interface TicketLocationReference {
id: string;
name: string;
}
export interface TicketLocation {
project: TicketLocationReference;
package: TicketLocationReference;
chainage: TicketLocationReference;
}
export interface TicketVideoMetadata {
duration_seconds: number;
fps: number;
resolution: {
width: number;
height: number;
label: string;
};
}
export interface TicketDetectionClassCount {
class_name: string;
display_name: string;
count: number;
}
export interface TicketOverviewDetail {
id: string;
ticket_name: string;
video_id: string;
chainage_id: string;
chainage_name: string;
location: TicketLocation;
video_metadata: TicketVideoMetadata;
uploader: {
user_id: number;
name: string;
@@ -95,6 +124,7 @@ export interface TicketOverviewDetail {
detection_count: number;
completed_at: string;
available_defect_classes: string[];
detections_by_class: TicketDetectionClassCount[];
};
timestamps: TicketTimestamps;
}