Compare commits
2 Commits
57b33bd061
...
b1174c8825
| Author | SHA1 | Date | |
|---|---|---|---|
| b1174c8825 | |||
| d4a7a7c139 |
@@ -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 />
|
||||||
|
|||||||
@@ -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,
|
(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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
'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">
|
||||||
|
|||||||
@@ -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 { 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user