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 />
)}