2 Commits

19 changed files with 322 additions and 181 deletions

View File

@@ -16,7 +16,7 @@ const ModulesLayout = ({
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden"> <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="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"> <div className="flex gap-3 items-center sticky top-0 bg-background z-50 py-3 px-6 border-b border-border">
<SidebarTrigger /> <SidebarTrigger />

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

View File

@@ -7,9 +7,13 @@ import { Button } from '@/components/ui/button';
import { SparkleButton } from '@/components/ui/sparkle-button'; import { SparkleButton } from '@/components/ui/sparkle-button';
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; 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 router = useRouter();
const ticketLabel = ticket.ticket_name; 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'; 'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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) { function formatCreatedDate(value: string | null | undefined) {
if (!value) return '-'; if (!value) return '-';
@@ -74,9 +74,11 @@ function OverviewField({
); );
} }
export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) { export function TicketOverviewCard({
const detectionCount = ticket.ai_result?.detection_count; ticket,
}: {
ticket: TicketOverviewDetail;
}) {
return ( return (
<Card> <Card>
<CardHeader> <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"> <CardContent className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-4">
<OverviewField label="Detection Count"> <OverviewField label="Detection Count">
<MetaValue> <MetaValue>{ticket.ai_result.detection_count} Occurrences</MetaValue>
{detectionCount != null ? `${detectionCount} Occurrences` : '-'}
</MetaValue>
</OverviewField> </OverviewField>
<OverviewField label="Chainage"> <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 { NoTicketAction } from './actions/NoTicketAction';
import { ReviewRepairAction } from './actions/ReviewRepairAction'; import { ReviewRepairAction } from './actions/ReviewRepairAction';
import { SubmitRepairAction } from './actions/SubmitRepairAction'; import { SubmitRepairAction } from './actions/SubmitRepairAction';
import { TicketDefectClassTabs } from './TicketDefectClassTabs';
interface TicketStatusActionsProps { interface TicketStatusActionsProps {
ticket: TicketDetail; 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} />; return <ClosedTicketAction ticket={ticket} />;
} }
@@ -57,10 +59,5 @@ function getTicketAction(ticket: TicketDetail) {
} }
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) { export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
return ( return getTicketAction(ticket);
<div className="space-y-3">
<TicketDefectClassTabs ticketId={ticket.id} />
{getTicketAction(ticket)}
</div>
);
} }

View File

@@ -6,14 +6,25 @@ import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { TicketDetailHeader } from './components/TicketDetailHeader'; 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 { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketOverviewCard } from './components/TicketOverviewCard'; import { TicketOverviewCard } from './components/TicketOverviewCard';
import { TicketRepairReportCard } from './components/TicketRepairReportCard'; import { TicketRepairReportCard } from './components/TicketRepairReportCard';
import { TicketStatusActions } from './components/TicketStatusActions'; import { TicketStatusActions } from './components/TicketStatusActions';
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents'; import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
import { useTicketDetailQuery } from '../hooks/useTicketQueries'; import {
useTicketClassDetailQuery,
useTicketOverviewQuery,
} from '../hooks/useTicketQueries';
export default function TicketDetailPage() { export default function TicketDetailPage() {
const router = useRouter(); const router = useRouter();
@@ -21,16 +32,17 @@ export default function TicketDetailPage() {
const { ticketId } = useParams() as { ticketId: string }; const { ticketId } = useParams() as { ticketId: string };
const defectClass = searchParams.get('defect_class') ?? undefined; const defectClass = searchParams.get('defect_class') ?? undefined;
const ticketQuery = useTicketDetailQuery(ticketId, defectClass); const overviewQuery = useTicketOverviewQuery(ticketId);
const ticket = ticketQuery.data; const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
const overview = overviewQuery.data;
const classDetail = classDetailQuery.data;
const isClassDetailLoading = Boolean(
defectClass && classDetailQuery.isLoading,
);
useTicketDetailEvents(ticketId, defectClass, Boolean(ticketId)); useTicketDetailEvents(ticketId, defectClass, Boolean(ticketId));
if (ticketQuery.isLoading) { if (overviewQuery.isError) {
return <TicketDetailSkeleton />;
}
if (ticketQuery.isError || !ticket) {
return ( return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3"> <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> <p className="text-sm text-destructive">Failed to load ticket.</p>
@@ -48,17 +60,34 @@ export default function TicketDetailPage() {
return ( return (
<main className="relative z-10 space-y-5"> <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="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]">
<div className="space-y-5"> <div className="space-y-5">
<TicketOverviewCard ticket={ticket} /> {overview ? (
<TicketRepairReportCard ticket={ticket} /> <TicketOverviewCard ticket={overview} />
<TicketHistoryCard ticket={ticket} /> ) : (
<TicketOverviewCardSkeleton />
)}
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
{!isClassDetailLoading && classDetail ? (
<>
<TicketRepairReportCard ticket={classDetail} />
<TicketHistoryCard ticket={classDetail} />
</>
) : null}
</div> </div>
<div className="self-start"> <div className="space-y-3 self-start">
<TicketStatusActions ticket={ticket} /> <TicketDefectClassTabs ticketId={ticketId} />
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
{!isClassDetailLoading && classDetail ? (
<TicketStatusActions ticket={classDetail} />
) : null}
</div> </div>
</div> </div>
</main> </main>

View File

@@ -30,10 +30,21 @@ export function useTicketColumns(): ColumnDef<TicketListItem>[] {
size: 120, size: 120,
}, },
{ {
accessorKey: 'created_by_name', id: 'created_by',
header: 'Uploaded By', header: 'Uploaded By',
size: 220, size: 220,
cell: ({ row }) => row.original.created_by_name || 'N/A', cell: ({ row }) => (
<div>
<p className="font-medium">
{row.original.created_by_name || 'N/A'}
</p>
{row.original.created_by_email ? (
<p className="text-xs text-muted-foreground">
{row.original.created_by_email}
</p>
) : null}
</div>
),
}, },
{ {
accessorKey: 'updated_at', accessorKey: 'updated_at',

View File

@@ -100,6 +100,7 @@ function buildTicketListItem(
default_defect_class: event.default_defect_class ?? null, default_defect_class: event.default_defect_class ?? null,
detection_count: event.detection_count, detection_count: event.detection_count,
created_by_name: event.created_by_name ?? null, created_by_name: event.created_by_name ?? null,
created_by_email: event.created_by_email ?? null,
created_at: event.created_at ?? event.updated_at, created_at: event.created_at ?? event.updated_at,
updated_at: event.updated_at, updated_at: event.updated_at,
}; };
@@ -118,6 +119,7 @@ function mergeTicketListItem(
event.default_defect_class ?? current.default_defect_class, event.default_defect_class ?? current.default_defect_class,
detection_count: event.detection_count ?? current.detection_count, detection_count: event.detection_count ?? current.detection_count,
created_by_name: event.created_by_name ?? current.created_by_name, created_by_name: event.created_by_name ?? current.created_by_name,
created_by_email: event.created_by_email ?? current.created_by_email,
created_at: event.created_at ?? current.created_at, created_at: event.created_at ?? current.created_at,
updated_at: event.updated_at ?? current.updated_at, updated_at: event.updated_at ?? current.updated_at,
}; };

View File

@@ -31,9 +31,12 @@ export function useTicketDetailEvents(
const refetchTicket = useCallback(() => { const refetchTicket = useCallback(() => {
if (!ticketId) return; if (!ticketId) return;
queryClient.refetchQueries({ queryClient.refetchQueries({ queryKey: ticketKeys.overview(ticketId) });
queryKey: ticketKeys.detail(ticketId, defectClassRef.current), if (defectClassRef.current) {
}); queryClient.refetchQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClassRef.current),
});
}
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.defectClasses(ticketId), queryKey: ticketKeys.defectClasses(ticketId),
}); });

View File

@@ -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, ticketId: string | undefined,
defectClass: string | undefined, defectClass: string | undefined,
) { ) {
return useQuery({ return useQuery({
queryKey: ticketKeys.detail(ticketId ?? '', defectClass), queryKey: ticketKeys.classDetail(ticketId ?? '', defectClass ?? ''),
queryFn: () => queryFn: () =>
ticketService.getTicketDetail(ticketId as string, defectClass), ticketService.getTicketClassDetail(
enabled: Boolean(ticketId), ticketId as string,
defectClass as string,
),
enabled: Boolean(ticketId && defectClass),
}); });
} }
@@ -102,7 +113,7 @@ export function useAssignTicketMutation(ticketId: string) {
onSuccess: (ticket, payload) => { onSuccess: (ticket, payload) => {
toast.success('Ticket assigned'); toast.success('Ticket assigned');
queryClient.setQueryData<TicketDetail>( queryClient.setQueryData<TicketDetail>(
ticketKeys.detail(ticketId, payload.defect_class), ticketKeys.classDetail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket), (current) => mergeTicketDetailResponse(current, ticket),
); );
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
@@ -120,7 +131,7 @@ export function useSubmitRepairMutation(ticketId: string) {
onSuccess: (ticket) => { onSuccess: (ticket) => {
toast.success('Repair submitted'); toast.success('Repair submitted');
queryClient.setQueryData<TicketDetail>( queryClient.setQueryData<TicketDetail>(
ticketKeys.detail(ticketId), ticketKeys.classDetail(ticketId, ticket.defect_class),
(current) => mergeTicketDetailResponse(current, ticket), (current) => mergeTicketDetailResponse(current, ticket),
); );
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
@@ -138,7 +149,7 @@ export function useSubmitRepairUploadMutation(ticketId: string) {
onSuccess: (ticket, payload) => { onSuccess: (ticket, payload) => {
toast.success('Repair evidence uploaded'); toast.success('Repair evidence uploaded');
queryClient.setQueryData<TicketDetail>( queryClient.setQueryData<TicketDetail>(
ticketKeys.detail(ticketId, payload.defect_class), ticketKeys.classDetail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket), (current) => mergeTicketDetailResponse(current, ticket),
); );
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
@@ -158,7 +169,7 @@ export function useReviewRepairMutation(ticketId: string) {
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected', payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
); );
queryClient.setQueryData<TicketDetail>( queryClient.setQueryData<TicketDetail>(
ticketKeys.detail(ticketId, payload.defect_class), ticketKeys.classDetail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket), (current) => mergeTicketDetailResponse(current, ticket),
); );
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
@@ -176,7 +187,7 @@ export function useCloseTicketMutation(ticketId: string) {
onSuccess: (ticket, payload) => { onSuccess: (ticket, payload) => {
toast.success('Ticket closed'); toast.success('Ticket closed');
queryClient.setQueryData<TicketDetail>( queryClient.setQueryData<TicketDetail>(
ticketKeys.detail(ticketId, payload.defect_class), ticketKeys.classDetail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket), (current) => mergeTicketDetailResponse(current, ticket),
); );
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });

View File

@@ -5,9 +5,11 @@ export const ticketKeys = {
lists: () => [...ticketKeys.all, 'list'] as const, lists: () => [...ticketKeys.all, 'list'] as const,
list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const, list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const,
details: () => [...ticketKeys.all, 'detail'] as const, details: () => [...ticketKeys.all, 'detail'] as const,
detail: (ticketId: string, defectClass?: string) => overview: (ticketId: string) =>
[...ticketKeys.details(), ticketId, defectClass] as const, [...ticketKeys.details(), ticketId, 'overview'] as const,
classDetail: (ticketId: string, defectClass: string) =>
[...ticketKeys.details(), ticketId, 'class', defectClass] as const,
defectClasses: (ticketId: string) => defectClasses: (ticketId: string) =>
[...ticketKeys.detail(ticketId), 'defect-classes'] as const, [...ticketKeys.details(), ticketId, 'defect-classes'] as const,
assignableUsers: () => [...ticketKeys.all, 'assignable-users'] as const, assignableUsers: () => [...ticketKeys.all, 'assignable-users'] as const,
}; };

View File

@@ -122,6 +122,20 @@
} }
@layer base { @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; @apply border-border outline-none;
} }

View File

@@ -10,6 +10,7 @@ import type {
SubmitRepairPayload, SubmitRepairPayload,
SubmitRepairUploadPayload, SubmitRepairUploadPayload,
TicketDetail, TicketDetail,
TicketOverviewDetail,
TicketDefectClassesResponse, TicketDefectClassesResponse,
TicketListParams, TicketListParams,
TicketListResponse, TicketListResponse,
@@ -32,15 +33,22 @@ export const ticketService = {
return response.data; return response.data;
}, },
getTicketDetail: async ( getTicketOverview: async (
ticketId: string, ticketId: string,
defectClass?: string, ): Promise<TicketOverviewDetail> => {
): Promise<TicketDetail> => { const response = await axiosClient.get<TicketOverviewDetail>(
const path = defectClass API_ROUTES.TICKETS.DETAIL(ticketId),
? API_ROUTES.TICKETS.CLASS_DETAIL(ticketId, defectClass) );
: 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; return response.data;
}, },

View File

@@ -65,6 +65,25 @@ export interface TicketTimestamps {
updated_at: string; 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 { export interface TicketDetail {
id: string; id: string;
ticket_name?: string; ticket_name?: string;

View File

@@ -16,6 +16,7 @@ export type TicketTableStatusEvent = {
default_defect_class?: string | null; default_defect_class?: string | null;
detection_count?: number; detection_count?: number;
created_by_name?: string | null; created_by_name?: string | null;
created_by_email?: string | null;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
message?: string; message?: string;

View File

@@ -12,6 +12,7 @@ export interface TicketListItem {
default_defect_class: string | null; default_defect_class: string | null;
detection_count: number; detection_count: number;
created_by_name: string | null; created_by_name: string | null;
created_by_email: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }