feat(ticket): split overview/class detail queries and add loading skeletons
This commit is contained in:
@@ -16,7 +16,7 @@ const ModulesLayout = ({
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="flex-1 overflow-auto [scrollbar-gutter:stable]">
|
||||
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
|
||||
<div className="flex gap-3 items-center sticky top-0 bg-background z-50 py-3 px-6 border-b border-border">
|
||||
<SidebarTrigger />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,25 @@ import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { TicketDetailHeader } from './components/TicketDetailHeader';
|
||||
import { TicketDetailSkeleton } from './components/TicketDetailSkeleton';
|
||||
import {
|
||||
TicketOverviewCardSkeleton,
|
||||
TicketOverviewHeaderSkeleton,
|
||||
} from './components/TicketOverviewSkeleton';
|
||||
import {
|
||||
TicketClassActionsSkeleton,
|
||||
TicketClassContentSkeleton,
|
||||
} from './components/TicketClassDetailSkeleton';
|
||||
|
||||
import { TicketHistoryCard } from './components/TicketHistoryCard';
|
||||
import { TicketOverviewCard } from './components/TicketOverviewCard';
|
||||
import { TicketRepairReportCard } from './components/TicketRepairReportCard';
|
||||
import { TicketStatusActions } from './components/TicketStatusActions';
|
||||
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
|
||||
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
|
||||
import { useTicketDetailQuery } from '../hooks/useTicketQueries';
|
||||
import {
|
||||
useTicketClassDetailQuery,
|
||||
useTicketOverviewQuery,
|
||||
} from '../hooks/useTicketQueries';
|
||||
|
||||
export default function TicketDetailPage() {
|
||||
const router = useRouter();
|
||||
@@ -21,16 +32,17 @@ export default function TicketDetailPage() {
|
||||
const { ticketId } = useParams() as { ticketId: string };
|
||||
const defectClass = searchParams.get('defect_class') ?? undefined;
|
||||
|
||||
const ticketQuery = useTicketDetailQuery(ticketId, defectClass);
|
||||
const ticket = ticketQuery.data;
|
||||
const overviewQuery = useTicketOverviewQuery(ticketId);
|
||||
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
|
||||
const overview = overviewQuery.data;
|
||||
const classDetail = classDetailQuery.data;
|
||||
const isClassDetailLoading = Boolean(
|
||||
defectClass && classDetailQuery.isLoading,
|
||||
);
|
||||
|
||||
useTicketDetailEvents(ticketId, defectClass, Boolean(ticketId));
|
||||
|
||||
if (ticketQuery.isLoading) {
|
||||
return <TicketDetailSkeleton />;
|
||||
}
|
||||
|
||||
if (ticketQuery.isError || !ticket) {
|
||||
if (overviewQuery.isError) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3">
|
||||
<p className="text-sm text-destructive">Failed to load ticket.</p>
|
||||
@@ -48,17 +60,34 @@ export default function TicketDetailPage() {
|
||||
|
||||
return (
|
||||
<main className="relative z-10 space-y-5">
|
||||
<TicketDetailHeader ticket={ticket} />
|
||||
{overview ? (
|
||||
<TicketDetailHeader ticket={overview} />
|
||||
) : (
|
||||
<TicketOverviewHeaderSkeleton />
|
||||
)}
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]">
|
||||
<div className="space-y-5">
|
||||
<TicketOverviewCard ticket={ticket} />
|
||||
<TicketRepairReportCard ticket={ticket} />
|
||||
<TicketHistoryCard ticket={ticket} />
|
||||
{overview ? (
|
||||
<TicketOverviewCard ticket={overview} />
|
||||
) : (
|
||||
<TicketOverviewCardSkeleton />
|
||||
)}
|
||||
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
|
||||
{!isClassDetailLoading && classDetail ? (
|
||||
<>
|
||||
<TicketRepairReportCard ticket={classDetail} />
|
||||
<TicketHistoryCard ticket={classDetail} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="self-start">
|
||||
<TicketStatusActions ticket={ticket} />
|
||||
<div className="space-y-3 self-start">
|
||||
<TicketDefectClassTabs ticketId={ticketId} />
|
||||
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
|
||||
{!isClassDetailLoading && classDetail ? (
|
||||
<TicketStatusActions ticket={classDetail} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -31,9 +31,12 @@ export function useTicketDetailEvents(
|
||||
const refetchTicket = useCallback(() => {
|
||||
if (!ticketId) return;
|
||||
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ticketKeys.detail(ticketId, defectClassRef.current),
|
||||
});
|
||||
queryClient.refetchQueries({ queryKey: ticketKeys.overview(ticketId) });
|
||||
if (defectClassRef.current) {
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClassRef.current),
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.defectClasses(ticketId),
|
||||
});
|
||||
|
||||
@@ -52,15 +52,26 @@ export function useTicketsQuery(params: UseTicketsQueryParams) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketDetailQuery(
|
||||
export function useTicketOverviewQuery(ticketId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.overview(ticketId ?? ''),
|
||||
queryFn: () => ticketService.getTicketOverview(ticketId as string),
|
||||
enabled: Boolean(ticketId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketClassDetailQuery(
|
||||
ticketId: string | undefined,
|
||||
defectClass: string | undefined,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.detail(ticketId ?? '', defectClass),
|
||||
queryKey: ticketKeys.classDetail(ticketId ?? '', defectClass ?? ''),
|
||||
queryFn: () =>
|
||||
ticketService.getTicketDetail(ticketId as string, defectClass),
|
||||
enabled: Boolean(ticketId),
|
||||
ticketService.getTicketClassDetail(
|
||||
ticketId as string,
|
||||
defectClass as string,
|
||||
),
|
||||
enabled: Boolean(ticketId && defectClass),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,7 +113,7 @@ export function useAssignTicketMutation(ticketId: string) {
|
||||
onSuccess: (ticket, payload) => {
|
||||
toast.success('Ticket assigned');
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.detail(ticketId, payload.defect_class),
|
||||
ticketKeys.classDetail(ticketId, payload.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
@@ -120,7 +131,7 @@ export function useSubmitRepairMutation(ticketId: string) {
|
||||
onSuccess: (ticket) => {
|
||||
toast.success('Repair submitted');
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.detail(ticketId),
|
||||
ticketKeys.classDetail(ticketId, ticket.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
@@ -138,7 +149,7 @@ export function useSubmitRepairUploadMutation(ticketId: string) {
|
||||
onSuccess: (ticket, payload) => {
|
||||
toast.success('Repair evidence uploaded');
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.detail(ticketId, payload.defect_class),
|
||||
ticketKeys.classDetail(ticketId, payload.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
@@ -158,7 +169,7 @@ export function useReviewRepairMutation(ticketId: string) {
|
||||
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
|
||||
);
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.detail(ticketId, payload.defect_class),
|
||||
ticketKeys.classDetail(ticketId, payload.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
@@ -176,7 +187,7 @@ export function useCloseTicketMutation(ticketId: string) {
|
||||
onSuccess: (ticket, payload) => {
|
||||
toast.success('Ticket closed');
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.detail(ticketId, payload.defect_class),
|
||||
ticketKeys.classDetail(ticketId, payload.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
|
||||
@@ -5,9 +5,11 @@ export const ticketKeys = {
|
||||
lists: () => [...ticketKeys.all, 'list'] as const,
|
||||
list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const,
|
||||
details: () => [...ticketKeys.all, 'detail'] as const,
|
||||
detail: (ticketId: string, defectClass?: string) =>
|
||||
[...ticketKeys.details(), ticketId, defectClass] as const,
|
||||
overview: (ticketId: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'overview'] as const,
|
||||
classDetail: (ticketId: string, defectClass: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'class', defectClass] as const,
|
||||
defectClasses: (ticketId: string) =>
|
||||
[...ticketKeys.detail(ticketId), 'defect-classes'] as const,
|
||||
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
|
||||
assignableUsers: () => [...ticketKeys.all, 'assignable-users'] as const,
|
||||
};
|
||||
|
||||
@@ -122,6 +122,20 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
html:has(body[data-scroll-locked]) {
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
|
||||
@supports not (scrollbar-gutter: stable) {
|
||||
html {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border outline-none;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
SubmitRepairPayload,
|
||||
SubmitRepairUploadPayload,
|
||||
TicketDetail,
|
||||
TicketOverviewDetail,
|
||||
TicketDefectClassesResponse,
|
||||
TicketListParams,
|
||||
TicketListResponse,
|
||||
@@ -32,15 +33,22 @@ export const ticketService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTicketDetail: async (
|
||||
getTicketOverview: async (
|
||||
ticketId: string,
|
||||
defectClass?: string,
|
||||
): Promise<TicketDetail> => {
|
||||
const path = defectClass
|
||||
? API_ROUTES.TICKETS.CLASS_DETAIL(ticketId, defectClass)
|
||||
: API_ROUTES.TICKETS.DETAIL(ticketId);
|
||||
): Promise<TicketOverviewDetail> => {
|
||||
const response = await axiosClient.get<TicketOverviewDetail>(
|
||||
API_ROUTES.TICKETS.DETAIL(ticketId),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
const response = await axiosClient.get<TicketDetail>(path);
|
||||
getTicketClassDetail: async (
|
||||
ticketId: string,
|
||||
defectClass: string,
|
||||
): Promise<TicketDetail> => {
|
||||
const response = await axiosClient.get<TicketDetail>(
|
||||
API_ROUTES.TICKETS.CLASS_DETAIL(ticketId, defectClass),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -65,6 +65,25 @@ export interface TicketTimestamps {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TicketOverviewDetail {
|
||||
id: string;
|
||||
ticket_name: string;
|
||||
video_id: string;
|
||||
chainage_id: string;
|
||||
chainage_name: string;
|
||||
uploader: {
|
||||
user_id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
ai_result: {
|
||||
detection_count: number;
|
||||
completed_at: string;
|
||||
available_defect_classes: string[];
|
||||
};
|
||||
timestamps: TicketTimestamps;
|
||||
}
|
||||
|
||||
export interface TicketDetail {
|
||||
id: string;
|
||||
ticket_name?: string;
|
||||
|
||||
Reference in New Issue
Block a user