feat(ticket): split overview/class detail queries and add loading skeletons

This commit is contained in:
2026-07-01 16:13:42 +05:30
parent d4a7a7c139
commit b1174c8825
15 changed files with 305 additions and 179 deletions

View File

@@ -0,0 +1,108 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function TicketClassContentSkeleton() {
return (
<div className="space-y-5" aria-label="Loading defect class details">
<Card>
<CardHeader className="sm:grid-cols-[1fr_auto]">
<Skeleton className="h-5 w-36" />
<Skeleton className="h-3 w-32 sm:col-start-2 sm:row-start-1" />
</CardHeader>
<CardContent className="space-y-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{Array.from({ length: 2 }).map((_, index) => (
<div key={index} className="space-y-1">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-36" />
</div>
))}
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<div className="rounded-lg border bg-muted/15 px-4 py-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="mt-2 h-4 w-3/4" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Skeleton className="aspect-video w-full rounded-md" />
<Skeleton className="aspect-video w-full rounded-md" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="space-y-2 rounded-lg border p-2">
<Skeleton className="aspect-4/3 w-full rounded-lg" />
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-56" />
</CardHeader>
<CardContent className="pl-5.5">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="relative flex gap-4 pb-8 last:pb-0">
{index < 2 ? (
<span className="absolute top-6 left-2.75 h-[calc(100%-10px)] w-px bg-border" />
) : null}
<Skeleton className="relative z-10 mt-0.5 size-6 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Skeleton className="h-4 w-44" />
<Skeleton className="h-3 w-28" />
</div>
<Skeleton className="h-3 w-56" />
<Skeleton className="h-12 w-full" />
</div>
</div>
))}
</CardContent>
</Card>
</div>
);
}
export function TicketClassActionsSkeleton() {
return (
<Card aria-label="Loading defect class actions">
<CardHeader>
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded" />
<Skeleton className="h-5 w-32" />
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-20 w-full" />
</div>
<Skeleton className="h-10 w-full" />
</div>
</CardContent>
</Card>
);
}

View File

@@ -37,15 +37,17 @@ export function TicketDefectClassTabs({
(item) => item.class_name === defectClassParam,
);
setSelectedClass((current) => {
if (hasUrlClass) return defectClassParam ?? undefined;
if (hasUrlClass) {
setSelectedClass(defectClassParam ?? undefined);
return;
}
return current &&
defectClasses.some((item) => item.class_name === current)
? current
: defectClasses[0].class_name;
});
}, [defectClassParam, defectClasses]);
const firstClass = defectClasses[0].class_name;
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('defect_class', firstClass);
setSelectedClass(firstClass);
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
}, [defectClassParam, defectClasses, pathname, router, searchParams]);
const handleClassChange = (className: string) => {
const nextParams = new URLSearchParams(searchParams.toString());
@@ -88,7 +90,10 @@ export function TicketDefectClassTabs({
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<span>Issues</span>
<Badge variant="secondary" className="min-w-5 min-h-5 justify-center rounded-full">
<Badge
variant="secondary"
className="min-w-5 min-h-5 justify-center rounded-full"
>
{defectClasses.length}
</Badge>
</CardTitle>

View File

@@ -7,9 +7,13 @@ import { Button } from '@/components/ui/button';
import { SparkleButton } from '@/components/ui/sparkle-button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { TicketDetail } from '@/types';
import type { TicketOverviewDetail } from '@/types';
export function TicketDetailHeader({ ticket }: { ticket: TicketDetail }) {
export function TicketDetailHeader({
ticket,
}: {
ticket: TicketOverviewDetail;
}) {
const router = useRouter();
const ticketLabel = ticket.ticket_name;

View File

@@ -1,115 +0,0 @@
'use client';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
function OverviewSkeletonField() {
return (
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-32" />
</div>
);
}
function TimelineSkeletonItem({ isLast = false }: { isLast?: boolean }) {
return (
<div className="relative flex gap-4 pb-8 last:pb-0">
{!isLast ? (
<span
aria-hidden
className="absolute top-6 left-2.75 h-[calc(100%-10px)] w-px bg-border"
/>
) : null}
<Skeleton className="relative z-10 mt-0.5 size-6 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Skeleton className="h-4 w-44" />
<Skeleton className="h-3 w-28" />
</div>
<Skeleton className="h-3 w-56" />
<Skeleton className="h-12 w-full" />
</div>
</div>
);
}
export function TicketDetailSkeleton() {
return (
<main className="relative z-10 space-y-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-6 w-24 rounded-full" />
</div>
<Skeleton className="h-3 w-72 max-w-full" />
</div>
<div className="flex shrink-0 items-center gap-2">
<Skeleton className="h-10 w-36" />
<Skeleton className="h-8 w-28" />
</div>
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]">
<div className="space-y-5">
<Card>
<CardHeader>
<Skeleton className="h-4 w-20" />
</CardHeader>
<CardContent className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-4">
{Array.from({ length: 8 }).map((_, index) => (
<OverviewSkeletonField key={index} />
))}
</CardContent>
</Card>
<section className="space-y-5">
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-44" />
</CardHeader>
<CardContent>
<Skeleton className="aspect-video w-full rounded-lg" />
</CardContent>
</Card>
</section>
<Card>
<CardHeader className="flex-row items-center">
<Skeleton className="size-5 rounded-full" />
<Skeleton className="h-5 w-56" />
</CardHeader>
<CardContent className="pl-5.5">
<TimelineSkeletonItem />
<TimelineSkeletonItem />
<TimelineSkeletonItem isLast />
</CardContent>
</Card>
</div>
<Card className="self-start">
<CardHeader>
<Skeleton className="h-5 w-36" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
</div>
</main>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { TicketActor, TicketDetail } from '@/types';
import type { TicketActor, TicketOverviewDetail } from '@/types';
function formatCreatedDate(value: string | null | undefined) {
if (!value) return '-';
@@ -74,9 +74,11 @@ function OverviewField({
);
}
export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
const detectionCount = ticket.ai_result?.detection_count;
export function TicketOverviewCard({
ticket,
}: {
ticket: TicketOverviewDetail;
}) {
return (
<Card>
<CardHeader>
@@ -85,9 +87,7 @@ export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
<CardContent className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-4">
<OverviewField label="Detection Count">
<MetaValue>
{detectionCount != null ? `${detectionCount} Occurrences` : '-'}
</MetaValue>
<MetaValue>{ticket.ai_result.detection_count} Occurrences</MetaValue>
</OverviewField>
<OverviewField label="Chainage">

View File

@@ -0,0 +1,41 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
function OverviewSkeletonField() {
return (
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-32" />
</div>
);
}
export function TicketOverviewHeaderSkeleton() {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-3 w-36 max-w-full" />
</div>
<div className="flex shrink-0 items-center gap-2">
<Skeleton className="h-8 w-28" />
</div>
</div>
);
}
export function TicketOverviewCardSkeleton() {
return (
<Card aria-label="Loading ticket overview">
<CardHeader>
<Skeleton className="h-4 w-20" />
</CardHeader>
<CardContent className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<OverviewSkeletonField key={index} />
))}
</CardContent>
</Card>
);
}

View File

@@ -9,7 +9,6 @@ import { ClosedTicketAction } from './actions/ClosedTicketAction';
import { NoTicketAction } from './actions/NoTicketAction';
import { ReviewRepairAction } from './actions/ReviewRepairAction';
import { SubmitRepairAction } from './actions/SubmitRepairAction';
import { TicketDefectClassTabs } from './TicketDefectClassTabs';
interface TicketStatusActionsProps {
ticket: TicketDetail;
@@ -49,7 +48,10 @@ function getTicketAction(ticket: TicketDetail) {
);
}
if (ticket.assignment_status === 'approved' || ticket.assignment_status === 'rejected') {
if (
ticket.assignment_status === 'approved' ||
ticket.assignment_status === 'rejected'
) {
return <ClosedTicketAction ticket={ticket} />;
}
@@ -57,10 +59,5 @@ function getTicketAction(ticket: TicketDetail) {
}
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
return (
<div className="space-y-3">
<TicketDefectClassTabs ticketId={ticket.id} />
{getTicketAction(ticket)}
</div>
);
return getTicketAction(ticket);
}