chore: class location change

This commit is contained in:
2026-07-07 12:02:29 +05:30
parent ba9731de86
commit d88978a830
5 changed files with 229 additions and 165 deletions

View File

@@ -2,22 +2,27 @@
import { useEffect, useMemo, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { AlertTriangle, Component } from 'lucide-react';
import { CalendarCheck2, Component, FileVideo, ListChecks } from 'lucide-react';
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 { DEFECT_CLASS_STATUS_CONFIG } from '@/constants/defectClassStatus';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import { cn } from '@/lib/utils';
import type { TicketOverviewDetail } from '@/types';
import { formatDate } from '@/utils/date';
import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
interface TicketDefectClassTabsProps {
ticketId: string;
ticket?: TicketOverviewDetail;
}
export function TicketDefectClassTabs({
ticketId,
ticket,
}: TicketDefectClassTabsProps) {
const router = useRouter();
const pathname = usePathname();
@@ -56,20 +61,27 @@ export function TicketDefectClassTabs({
setSelectedClass(className);
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
};
const selectedIssue = ticket?.ai_result.detections_by_class.find(
(item) => item.class_name === selectedClass,
);
const selectedIssueVisual = getDefectVisual(selectedIssue?.class_name ?? '');
const SelectedIssueIcon = selectedIssueVisual.icon;
const completedAt = ticket?.ai_result.completed_at;
const videoName = ticket?.video?.name;
if (defectClassesQuery.isLoading) {
return (
<Card className="w-full">
<CardHeader>
<CardContent className="space-y-4 p-5">
<div className="h-5 w-24 animate-pulse rounded bg-muted" />
</CardHeader>
<CardContent className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="h-11 w-full animate-pulse rounded-lg bg-muted"
/>
))}
</div>
</CardContent>
</Card>
);
@@ -87,19 +99,19 @@ export function TicketDefectClassTabs({
className="w-full"
>
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="size-5 shrink-0 text-primary" />
<span>Issues</span>
<CardContent className="grid gap-0 p-0 lg:grid-cols-[minmax(220px,280px)_1fr] lg:divide-x lg:divide-border">
<div className="space-y-4 p-5">
<div className="flex items-center gap-2">
<ListChecks className="size-5 shrink-0 text-primary" />
<h2 className="text-base font-semibold">Detected Issues</h2>
<Badge
variant="secondary"
className="min-w-5 min-h-5 justify-center rounded-full"
>
{defectClasses.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
</div>
<TabsList className="flex h-auto w-full flex-col items-stretch gap-2 bg-transparent p-0">
{defectClasses.map((item) => {
const statusConfig =
@@ -130,6 +142,93 @@ export function TicketDefectClassTabs({
);
})}
</TabsList>
</div>
<div className="min-w-0 p-5">
{selectedIssue ? (
<div className="space-y-4">
<div className="flex min-w-0 items-center gap-3">
<span
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full',
selectedIssueVisual.cardClassName,
)}
>
<SelectedIssueIcon
className={cn(
'size-5',
selectedIssueVisual.colorClassName,
)}
/>
</span>
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
<h3 className="truncate text-lg font-semibold text-foreground">
{selectedIssue.display_name}
</h3>
<Badge
variant="secondary"
className={cn(
'shrink-0 rounded-full border-0 px-2.5 py-1 text-xs font-semibold',
selectedIssueVisual.cardClassName,
selectedIssueVisual.colorClassName,
)}
>
{selectedIssue.count} Detections
</Badge>
</div>
</div>
<div className="border-t pt-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">
Current Issue
</p>
<p className="text-sm font-semibold text-foreground">
{selectedIssue.display_name}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">
Current Issue Count
</p>
<p className="text-sm font-semibold text-violet-600">
{selectedIssue.count}
</p>
</div>
{completedAt ? (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<CalendarCheck2 className="size-4" />
<span>AI Completed At</span>
</div>
<p className="text-sm font-semibold text-foreground">
{formatDate(completedAt)}
</p>
</div>
) : null}
{videoName ? (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<FileVideo className="size-4" />
<span>Video</span>
</div>
<p className="truncate text-sm font-semibold text-foreground">
{videoName}
</p>
</div>
) : null}
</div>
</div>
</div>
) : (
<div className="flex min-h-48 items-center justify-center text-center">
<p className="text-sm font-medium text-muted-foreground">
Select an issue to view details.
</p>
</div>
)}
</div>
</CardContent>
</Card>
</Tabs>

View File

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

View File

@@ -6,7 +6,6 @@ import {
CalendarDays,
Clock3,
Gauge,
LayoutDashboard,
MapPin,
Monitor,
UserRound,
@@ -62,13 +61,11 @@ function SummaryCard({
value,
colorClassName,
cardClassName,
icon: Icon,
}: {
label: string;
value: number;
colorClassName: string;
cardClassName: string;
icon?: IconComponent;
}) {
return (
<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({
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 videoMetadata = ticket.video_metadata;
const duration =
typeof videoMetadata?.duration_seconds === 'number'
? `${Math.floor(videoMetadata.duration_seconds / 60)}:${Math.floor(
videoMetadata.duration_seconds % 60,
)
.toString()
.padStart(2, '0')}`
: null;
const chainageName =
ticket.location?.chainage?.name || ticket.chainage_name || null;
const packageName = ticket.location?.package?.name || null;
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 (
<div className="space-y-5">
{uploadedOn || uploaderName || locationLabel ? (
<Card>
<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">
<CalendarDays className="size-5 shrink-0 text-muted-foreground" />
<OverviewField label="Uploaded On">
<MetaValue>{formatDate(ticket.timestamps.created_at)}</MetaValue>
<MetaValue>{formatDate(uploadedOn)}</MetaValue>
</OverviewField>
</div>
) : null}
{uploaderName ? (
<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>
<MetaValue>{uploaderName}</MetaValue>
</OverviewField>
</div>
) : null}
{locationLabel ? (
<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>
) : null}
</CardContent>
</Card>
) : null}
<Card>
<CardHeader>
@@ -185,14 +171,14 @@ export function TicketOverviewCard({
<Activity className="size-5 text-primary" />
AI Detection Summary
</CardTitle>
{ticket.video_id ? (
{analysisVideoId ? (
<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}`)}
onClick={() => router.push(`/results/${analysisVideoId}`)}
>
View Analysis
<ArrowRight className="size-3.5" />
@@ -216,53 +202,31 @@ export function TicketOverviewCard({
key={item.class_name}
label={item.display_name}
value={item.count}
icon={visual.icon}
colorClassName={visual.colorClassName}
cardClassName={visual.cardClassName}
/>
);
})}
</div>
{hasVideoMetrics ? (
<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`}
/>
{videoName ? (
<VideoMetric icon={Monitor} label={videoName} />
) : null}
{videoMetadata?.fps ? (
<VideoMetric icon={Gauge} label={`${videoMetadata.fps} FPS`} />
) : null}
{duration ? (
<VideoMetric icon={Clock3} label={`${duration} Duration`} />
) : null}
{videoMetadata?.resolution?.label ? (
<VideoMetric
icon={Monitor}
label={`${ticket.video_metadata.resolution.label} Resolution`}
label={`${videoMetadata.resolution.label} Resolution`}
/>
) : null}
</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}
/>
) : null}
</CardContent>
</Card>
</div>

View File

@@ -68,10 +68,11 @@ 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} defectClass={defectClass} />
<TicketOverviewCard ticket={overview} />
) : (
<TicketOverviewCardSkeleton />
)}
<TicketDefectClassTabs ticketId={ticketId} ticket={overview} />
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
{!isClassDetailLoading && classDetail ? (
<TicketHistoryCard ticket={classDetail} />
@@ -79,7 +80,6 @@ export default function TicketDetailPage() {
</div>
<div className="space-y-3 self-start">
<TicketDefectClassTabs ticketId={ticketId} />
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
{!isClassDetailLoading && classDetail ? (
<TicketStatusActions ticket={classDetail} />

View File

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