refactor: simplify ticket lifecycle flow, remove processing and completed state
This commit is contained in:
@@ -88,7 +88,7 @@ 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 className="min-w-5 justify-center rounded-full px-1.5 py-0.5 text-xs tabular-nums">
|
<Badge variant="secondary" className="min-w-5 min-h-5 justify-center rounded-full">
|
||||||
{defectClasses.length}
|
{defectClasses.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
|||||||
@@ -9,26 +9,19 @@ import { PERMISSIONS } from '@/constants/permissions';
|
|||||||
import { PermissionGuard } from '@/guards';
|
import { PermissionGuard } from '@/guards';
|
||||||
import type { TicketDetail } from '@/types';
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
import { TicketStatusBadge } from '../../components/TicketStatusBadge';
|
|
||||||
|
|
||||||
export function TicketDetailHeader({ ticket }: { ticket: TicketDetail }) {
|
export function TicketDetailHeader({ ticket }: { ticket: TicketDetail }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const ticketLabel = ticket.ticket_name
|
const ticketLabel = ticket.ticket_name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<h1 className="text-2xl font-semibold tracking-tight">Ticket Detail</h1>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
|
||||||
Ticket Detail
|
|
||||||
</h1>
|
|
||||||
<TicketStatusBadge status={ticket.status} />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs ">Ticket: {ticketLabel}</p>
|
<p className="text-xs ">Ticket: {ticketLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{ticket.video_id && ticket.status !== 'processing' ? (
|
{ticket.video_id ? (
|
||||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||||
<SparkleButton
|
<SparkleButton
|
||||||
className="h-10 min-w-36 px-5"
|
className="h-10 min-w-36 px-5"
|
||||||
|
|||||||
@@ -7,22 +7,19 @@ import { cn } from '@/lib/utils';
|
|||||||
import type { TicketDetail, TicketHistoryItem, TicketStatus } from '@/types';
|
import type { TicketDetail, TicketHistoryItem, TicketStatus } from '@/types';
|
||||||
import { formatDate } from '@/utils/date';
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
import { formatTicketStatus } from '../../components/TicketStatusBadge';
|
|
||||||
|
|
||||||
function getActorLabel(item: TicketHistoryItem) {
|
function getActorLabel(item: TicketHistoryItem) {
|
||||||
return item.actor?.name || item.actor?.email || 'System';
|
return item.actor?.name || item.actor?.email || 'System';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLifecycleTitle(status: TicketStatus) {
|
function getLifecycleTitle(status: TicketStatus) {
|
||||||
const titles: Record<TicketStatus, string> = {
|
const titles: Record<TicketStatus, string> = {
|
||||||
processing: 'Incident Ticket Created',
|
|
||||||
unassigned: 'Awaiting Assignment',
|
unassigned: 'Awaiting Assignment',
|
||||||
assigned: 'Dispatched to Maintenance',
|
assigned: 'Dispatched to Maintenance',
|
||||||
under_review: 'Maintenance Finalized',
|
under_review: 'Maintenance Finalized',
|
||||||
closed: 'Ticket Closed',
|
closed: 'Ticket Closed',
|
||||||
};
|
};
|
||||||
|
|
||||||
return titles[status] ?? formatTicketStatus(status);
|
return titles[status];
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistoryEntry({
|
function HistoryEntry({
|
||||||
@@ -39,7 +36,7 @@ function HistoryEntry({
|
|||||||
{!isLast ? (
|
{!isLast ? (
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute top-6 left-[11px] h-[calc(100%-10px)] w-px bg-border"
|
className="absolute top-6 left-2.75 h-[calc(100%-10px)] w-px bg-border"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -56,7 +53,7 @@ function HistoryEntry({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'size-2 rounded-full bg-primary',
|
'size-2 rounded-full bg-primary',
|
||||||
isClosed &&
|
isClosed &&
|
||||||
'h-1.5 w-2.5 rotate-[-45deg] rounded-none border-b-2 border-l-2 border-current bg-transparent',
|
'h-1.5 w-2.5 -rotate-45 rounded-none border-b-2 border-l-2 border-current bg-transparent',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import type { TicketActor, TicketDetail } from '@/types';
|
import type { TicketActor, TicketDetail } from '@/types';
|
||||||
import { formatDate } from '@/utils/date';
|
|
||||||
|
|
||||||
function formatCreatedDate(value: string | null | undefined) {
|
function formatCreatedDate(value: string | null | undefined) {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
@@ -111,12 +104,6 @@ export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
|
|||||||
</MetaValue>
|
</MetaValue>
|
||||||
</OverviewField>
|
</OverviewField>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{ticket.ai_result?.completed_at ? (
|
|
||||||
<CardFooter className="text-xs text-muted-foreground">
|
|
||||||
AI analysis completed {formatDate(ticket.ai_result.completed_at)}
|
|
||||||
</CardFooter>
|
|
||||||
) : null}
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,39 +3,24 @@
|
|||||||
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 { TicketDetail } from '@/types';
|
||||||
import type { TicketDetailLiveState } from '../../hooks/useTicketDetailEvents';
|
|
||||||
|
|
||||||
import { AssignTicketAction } from './actions/AssignTicketAction';
|
import { AssignTicketAction } from './actions/AssignTicketAction';
|
||||||
import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
||||||
import { NoTicketAction } from './actions/NoTicketAction';
|
import { NoTicketAction } from './actions/NoTicketAction';
|
||||||
import { ProcessingTicketAction } from './actions/ProcessingTicketAction';
|
|
||||||
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
||||||
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
||||||
import { TicketDefectClassTabs } from './TicketDefectClassTabs';
|
import { TicketDefectClassTabs } from './TicketDefectClassTabs';
|
||||||
|
|
||||||
interface TicketStatusActionsProps {
|
interface TicketStatusActionsProps {
|
||||||
ticket: TicketDetail;
|
ticket: TicketDetail;
|
||||||
liveState: TicketDetailLiveState;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTicketAction(
|
function getTicketAction(ticket: TicketDetail) {
|
||||||
ticket: TicketDetail,
|
|
||||||
liveState: TicketDetailLiveState,
|
|
||||||
) {
|
|
||||||
if (ticket.status === 'processing') {
|
|
||||||
return (
|
|
||||||
<ProcessingTicketAction
|
|
||||||
progress={liveState.progress}
|
|
||||||
errorMessage={liveState.errorMessage}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ticket.assignment_status === 'unassigned') {
|
if (ticket.assignment_status === 'unassigned') {
|
||||||
return (
|
return (
|
||||||
<PermissionGuard
|
<PermissionGuard
|
||||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||||
fallback={<NoTicketAction status={ticket.status} />}
|
fallback={<NoTicketAction />}
|
||||||
>
|
>
|
||||||
<AssignTicketAction ticket={ticket} />
|
<AssignTicketAction ticket={ticket} />
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
@@ -46,7 +31,7 @@ function getTicketAction(
|
|||||||
return (
|
return (
|
||||||
<PermissionGuard
|
<PermissionGuard
|
||||||
permissions={PERMISSIONS.TICKET.WORK}
|
permissions={PERMISSIONS.TICKET.WORK}
|
||||||
fallback={<NoTicketAction status={ticket.status} />}
|
fallback={<NoTicketAction />}
|
||||||
>
|
>
|
||||||
<SubmitRepairAction ticket={ticket} />
|
<SubmitRepairAction ticket={ticket} />
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
@@ -57,7 +42,7 @@ function getTicketAction(
|
|||||||
return (
|
return (
|
||||||
<PermissionGuard
|
<PermissionGuard
|
||||||
permissions={PERMISSIONS.TICKET.REVIEW}
|
permissions={PERMISSIONS.TICKET.REVIEW}
|
||||||
fallback={<NoTicketAction status={ticket.status} />}
|
fallback={<NoTicketAction />}
|
||||||
>
|
>
|
||||||
<ReviewRepairAction ticket={ticket} />
|
<ReviewRepairAction ticket={ticket} />
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
@@ -68,17 +53,14 @@ function getTicketAction(
|
|||||||
return <ClosedTicketAction ticket={ticket} />;
|
return <ClosedTicketAction ticket={ticket} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <NoTicketAction status={ticket.status} />;
|
return <NoTicketAction />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketStatusActions({
|
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
|
||||||
ticket,
|
|
||||||
liveState,
|
|
||||||
}: TicketStatusActionsProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<TicketDefectClassTabs ticketId={ticket.id} />
|
<TicketDefectClassTabs ticketId={ticket.id} />
|
||||||
{getTicketAction(ticket, liveState)}
|
{getTicketAction(ticket)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { CircleOff, Clock } from 'lucide-react';
|
import { CircleOff } from 'lucide-react';
|
||||||
|
|
||||||
import type { TicketStatus } from '@/types';
|
|
||||||
|
|
||||||
import { TicketActionCard } from './TicketActionCard';
|
import { TicketActionCard } from './TicketActionCard';
|
||||||
|
|
||||||
const statusConfig: Record<
|
export function NoTicketAction() {
|
||||||
'processing',
|
|
||||||
{ icon: typeof Clock; title: string; message: string }
|
|
||||||
> = {
|
|
||||||
processing: {
|
|
||||||
icon: Clock,
|
|
||||||
title: 'Processing',
|
|
||||||
message: 'This ticket is still being processed. Check back shortly.',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NoTicketAction({ status }: { status: TicketStatus }) {
|
|
||||||
const config =
|
|
||||||
status === 'processing'
|
|
||||||
? statusConfig[status]
|
|
||||||
: {
|
|
||||||
icon: CircleOff,
|
|
||||||
title: 'No Actions',
|
|
||||||
message: 'No action available for this ticket.',
|
|
||||||
};
|
|
||||||
|
|
||||||
const Icon = config.icon;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TicketActionCard icon={Icon} title={config.title}>
|
<TicketActionCard icon={CircleOff} title="No Actions">
|
||||||
<p className="text-sm text-muted-foreground">{config.message}</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No action available for this ticket.
|
||||||
|
</p>
|
||||||
</TicketActionCard>
|
</TicketActionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { AlertCircle, Loader } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import type { TicketProcessingProgress } from '../../../hooks/useTicketDetailEvents';
|
|
||||||
|
|
||||||
import { TicketActionCard } from './TicketActionCard';
|
|
||||||
|
|
||||||
interface ProcessingTicketActionProps {
|
|
||||||
progress: TicketProcessingProgress | null;
|
|
||||||
errorMessage: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProgressMessage(progress: TicketProcessingProgress | null) {
|
|
||||||
if (!progress) return 'Waiting for analysis updates.';
|
|
||||||
if (progress.progress >= 95) {
|
|
||||||
return progress.message ?? 'Rendering annotated video...';
|
|
||||||
}
|
|
||||||
return progress.message ?? 'Processing video.';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProcessingTicketAction({
|
|
||||||
progress,
|
|
||||||
errorMessage,
|
|
||||||
}: ProcessingTicketActionProps) {
|
|
||||||
const progressValue = progress ? progress.progress : 0;
|
|
||||||
const hasProgress = Boolean(progress);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TicketActionCard
|
|
||||||
icon={Loader}
|
|
||||||
iconClassName="animate-spin"
|
|
||||||
title="Analyzing Video"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{getProgressMessage(progress)}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-muted-foreground">
|
|
||||||
{hasProgress ? `${Math.round(progressValue)}%` : '--'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={hasProgress ? progressValue : 0} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{errorMessage && (
|
|
||||||
<div className="flex gap-2 rounded-md border border-destructive/25 bg-destructive/10 p-3 text-sm text-destructive">
|
|
||||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
|
||||||
<span>{errorMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TicketActionCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -24,11 +24,7 @@ export default function TicketDetailPage() {
|
|||||||
const ticketQuery = useTicketDetailQuery(ticketId, defectClass);
|
const ticketQuery = useTicketDetailQuery(ticketId, defectClass);
|
||||||
const ticket = ticketQuery.data;
|
const ticket = ticketQuery.data;
|
||||||
|
|
||||||
const liveState = useTicketDetailEvents(
|
useTicketDetailEvents(ticketId, defectClass, Boolean(ticketId));
|
||||||
ticketId,
|
|
||||||
defectClass,
|
|
||||||
Boolean(ticketId),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (ticketQuery.isLoading) {
|
if (ticketQuery.isLoading) {
|
||||||
return <TicketDetailSkeleton />;
|
return <TicketDetailSkeleton />;
|
||||||
@@ -62,7 +58,7 @@ export default function TicketDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-start">
|
<div className="self-start">
|
||||||
<TicketStatusActions ticket={ticket} liveState={liveState} />
|
<TicketStatusActions ticket={ticket} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { ColumnDef } from '@tanstack/react-table';
|
|||||||
|
|
||||||
import type { TicketListItem } from '@/types';
|
import type { TicketListItem } from '@/types';
|
||||||
|
|
||||||
import { TicketStatusBadge } from './TicketStatusBadge';
|
|
||||||
import { formatDate } from '@/utils/date';
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
||||||
@@ -19,12 +18,6 @@ export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
|||||||
<span className="font-medium">{row.original.ticket_name}</span>
|
<span className="font-medium">{row.original.ticket_name}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: 'status',
|
|
||||||
header: 'Status',
|
|
||||||
size: 150,
|
|
||||||
cell: ({ row }) => <TicketStatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: 'chainage_name',
|
accessorKey: 'chainage_name',
|
||||||
header: 'Segment',
|
header: 'Segment',
|
||||||
|
|||||||
@@ -1,60 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { SegmentSelect } from '@/components/lookups/SegmentSelect';
|
import { SegmentSelect } from '@/components/lookups/SegmentSelect';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import type { TicketStatus } from '@/types';
|
|
||||||
|
|
||||||
import { formatTicketStatus } from './TicketStatusBadge';
|
|
||||||
|
|
||||||
const ticketStatuses: TicketStatus[] = [
|
|
||||||
'processing',
|
|
||||||
'unassigned',
|
|
||||||
'assigned',
|
|
||||||
'under_review',
|
|
||||||
'closed',
|
|
||||||
];
|
|
||||||
|
|
||||||
interface TicketFiltersProps {
|
interface TicketFiltersProps {
|
||||||
status: TicketStatus | 'all';
|
|
||||||
segmentId: string;
|
segmentId: string;
|
||||||
onStatusChange: (status: TicketStatus | 'all') => void;
|
|
||||||
onSegmentChange: (segmentId: string) => void;
|
onSegmentChange: (segmentId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketFilters({
|
export function TicketFilters({
|
||||||
status,
|
|
||||||
segmentId,
|
segmentId,
|
||||||
onStatusChange,
|
|
||||||
onSegmentChange,
|
onSegmentChange,
|
||||||
}: TicketFiltersProps) {
|
}: TicketFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div className="w-full sm:w-64">
|
||||||
<Select
|
<SegmentSelect value={segmentId} onValueChange={onSegmentChange} />
|
||||||
value={status}
|
|
||||||
onValueChange={(value) => onStatusChange(value as TicketStatus | 'all')}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full sm:w-48">
|
|
||||||
<SelectValue placeholder="Status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All statuses</SelectItem>
|
|
||||||
{ticketStatuses.map((item) => (
|
|
||||||
<SelectItem key={item} value={item}>
|
|
||||||
{formatTicketStatus(item)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<div className="w-full sm:w-64">
|
|
||||||
<SegmentSelect value={segmentId} onValueChange={onSegmentChange} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { cn } from '@/lib/utils';
|
|||||||
import type { TicketStatus } from '@/types';
|
import type { TicketStatus } from '@/types';
|
||||||
|
|
||||||
const statusClasses: Record<TicketStatus, string> = {
|
const statusClasses: Record<TicketStatus, string> = {
|
||||||
processing: 'bg-sky-100 text-sky-700 border-sky-200',
|
|
||||||
unassigned: 'bg-amber-100 text-amber-800 border-amber-200',
|
unassigned: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||||
assigned: 'bg-indigo-100 text-indigo-700 border-indigo-200',
|
assigned: 'bg-indigo-100 text-indigo-700 border-indigo-200',
|
||||||
under_review: 'bg-violet-100 text-violet-700 border-violet-200',
|
under_review: 'bg-violet-100 text-violet-700 border-violet-200',
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function TicketTable({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
toolbar={toolbar}
|
toolbar={toolbar}
|
||||||
emptyTitle="No tickets found."
|
emptyTitle="No tickets found."
|
||||||
emptyDescription="Ticket rows will appear after video processing creates them."
|
emptyDescription="Tickets will appear when road issues are detected."
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -1,30 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { API_ROUTES } from '@/constants/apiRoutes';
|
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||||
import { useSseWithToken } from '@/hooks/sse/useSseWithToken';
|
import { useSseWithToken } from '@/hooks/sse/useSseWithToken';
|
||||||
import { ticketService } from '@/services/api';
|
import { ticketService } from '@/services/api';
|
||||||
import type {
|
import type { TicketDetailStatusEvent } from '@/types';
|
||||||
TicketDetailStatusEvent,
|
|
||||||
TicketProgressEvent,
|
|
||||||
TicketStreamErrorEvent,
|
|
||||||
} from '@/types';
|
|
||||||
|
|
||||||
import { ticketKeys } from '../queries/ticketKeys';
|
import { ticketKeys } from '../queries/ticketKeys';
|
||||||
|
|
||||||
export interface TicketProcessingProgress {
|
|
||||||
progress: number;
|
|
||||||
message: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TicketDetailLiveState {
|
|
||||||
progress: TicketProcessingProgress | null;
|
|
||||||
errorMessage: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TICKET_DETAIL_RECONNECT_EVENTS = ['error'] as const;
|
const TICKET_DETAIL_RECONNECT_EVENTS = ['error'] as const;
|
||||||
|
|
||||||
function isHeartbeatEvent(event: { type?: string } | null | undefined) {
|
function isHeartbeatEvent(event: { type?: string } | null | undefined) {
|
||||||
@@ -35,14 +20,9 @@ export function useTicketDetailEvents(
|
|||||||
ticketId: string | undefined,
|
ticketId: string | undefined,
|
||||||
defectClass: string | undefined,
|
defectClass: string | undefined,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
): TicketDetailLiveState {
|
): void {
|
||||||
const router = useRouter();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const defectClassRef = useRef(defectClass);
|
const defectClassRef = useRef(defectClass);
|
||||||
const [progress, setProgress] = useState<TicketProcessingProgress | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
defectClassRef.current = defectClass;
|
defectClassRef.current = defectClass;
|
||||||
@@ -60,18 +40,6 @@ export function useTicketDetailEvents(
|
|||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
}, [queryClient, ticketId]);
|
}, [queryClient, ticketId]);
|
||||||
|
|
||||||
const selectDefectClass = useCallback(
|
|
||||||
(nextDefectClass: string | null | undefined) => {
|
|
||||||
if (!ticketId || !nextDefectClass || defectClassRef.current) return;
|
|
||||||
|
|
||||||
router.replace(
|
|
||||||
`/ticket/${ticketId}?defect_class=${encodeURIComponent(nextDefectClass)}`,
|
|
||||||
{ scroll: false },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[router, ticketId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getToken = useCallback(
|
const getToken = useCallback(
|
||||||
() => ticketService.createTicketEventsToken(ticketId as string),
|
() => ticketService.createTicketEventsToken(ticketId as string),
|
||||||
[ticketId],
|
[ticketId],
|
||||||
@@ -89,30 +57,10 @@ export function useTicketDetailEvents(
|
|||||||
if (isHeartbeatEvent(event)) return;
|
if (isHeartbeatEvent(event)) return;
|
||||||
refetchTicket();
|
refetchTicket();
|
||||||
},
|
},
|
||||||
progress: (event: TicketProgressEvent) => {
|
error: () => undefined,
|
||||||
setErrorMessage(null);
|
|
||||||
setProgress({
|
|
||||||
progress: event.progress,
|
|
||||||
message: event.message,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
complete: (event: TicketProgressEvent) => {
|
|
||||||
setErrorMessage(null);
|
|
||||||
setProgress({
|
|
||||||
progress: 100,
|
|
||||||
message: event.message,
|
|
||||||
});
|
|
||||||
selectDefectClass(event.defect_class);
|
|
||||||
refetchTicket();
|
|
||||||
},
|
|
||||||
error: (event: TicketStreamErrorEvent) => {
|
|
||||||
setErrorMessage(
|
|
||||||
event.message ?? 'Live ticket updates disconnected. Reconnecting...',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
heartbeat: () => undefined,
|
heartbeat: () => undefined,
|
||||||
}),
|
}),
|
||||||
[refetchTicket, selectDefectClass],
|
[refetchTicket],
|
||||||
);
|
);
|
||||||
|
|
||||||
const shouldStop = useCallback(
|
const shouldStop = useCallback(
|
||||||
@@ -122,15 +70,6 @@ export function useTicketDetailEvents(
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onConnectionError = useCallback(() => {
|
|
||||||
setErrorMessage('Live ticket updates disconnected. Reconnecting...');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setProgress(null);
|
|
||||||
setErrorMessage(null);
|
|
||||||
}, [ticketId]);
|
|
||||||
|
|
||||||
useSseWithToken({
|
useSseWithToken({
|
||||||
enabled: Boolean(enabled && ticketId),
|
enabled: Boolean(enabled && ticketId),
|
||||||
getToken,
|
getToken,
|
||||||
@@ -138,8 +77,5 @@ export function useTicketDetailEvents(
|
|||||||
events,
|
events,
|
||||||
reconnectOnEvents: TICKET_DETAIL_RECONNECT_EVENTS,
|
reconnectOnEvents: TICKET_DETAIL_RECONNECT_EVENTS,
|
||||||
shouldStop,
|
shouldStop,
|
||||||
onConnectionError,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { progress, errorMessage };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,29 +23,26 @@ const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
|
|||||||
interface UseTicketsQueryParams {
|
interface UseTicketsQueryParams {
|
||||||
skip: number;
|
skip: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
status?: TicketStatus | 'all';
|
|
||||||
chainageId?: string;
|
chainageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTicketListParams({
|
function buildTicketListParams({
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
status,
|
|
||||||
chainageId,
|
chainageId,
|
||||||
}: UseTicketsQueryParams): TicketListParams {
|
}: UseTicketsQueryParams): TicketListParams {
|
||||||
return {
|
return {
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
status: status === 'all' ? undefined : status,
|
|
||||||
chainage_id: chainageId || undefined,
|
chainage_id: chainageId || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTicketsQuery(params: UseTicketsQueryParams) {
|
export function useTicketsQuery(params: UseTicketsQueryParams) {
|
||||||
const { skip, limit, status, chainageId } = params;
|
const { skip, limit, chainageId } = params;
|
||||||
const listParams = useMemo(
|
const listParams = useMemo(
|
||||||
() => buildTicketListParams({ skip, limit, status, chainageId }),
|
() => buildTicketListParams({ skip, limit, chainageId }),
|
||||||
[chainageId, limit, skip, status],
|
[chainageId, limit, skip],
|
||||||
);
|
);
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -115,23 +112,6 @@ export function useAssignTicketMutation(ticketId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStartTicketMutation(ticketId: string) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: () => ticketService.startTicket(ticketId),
|
|
||||||
onSuccess: (ticket) => {
|
|
||||||
toast.success('Ticket started');
|
|
||||||
queryClient.setQueryData<TicketDetail>(
|
|
||||||
ticketKeys.detail(ticketId),
|
|
||||||
(current) => mergeTicketDetailResponse(current, ticket),
|
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
|
||||||
},
|
|
||||||
onError: () => toast.error('Failed to start ticket'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSubmitRepairMutation(ticketId: string) {
|
export function useSubmitRepairMutation(ticketId: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useCallback, useMemo, useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { Ticket } from 'lucide-react';
|
import { Ticket } from 'lucide-react';
|
||||||
import type { TicketListItem, TicketStatus } from '@/types';
|
import type { TicketListItem } from '@/types';
|
||||||
|
|
||||||
import { TicketFilters } from './components/TicketFilters';
|
import { TicketFilters } from './components/TicketFilters';
|
||||||
import { TicketTable } from './components/TicketTable';
|
import { TicketTable } from './components/TicketTable';
|
||||||
@@ -16,7 +16,6 @@ export default function TicketPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [skip, setSkip] = useState(0);
|
const [skip, setSkip] = useState(0);
|
||||||
const [limit, setLimit] = useState(10);
|
const [limit, setLimit] = useState(10);
|
||||||
const [status, setStatus] = useState<TicketStatus | 'all'>('all');
|
|
||||||
const [segmentId, setSegmentId] = useState('');
|
const [segmentId, setSegmentId] = useState('');
|
||||||
|
|
||||||
useTenantTicketTableEvents();
|
useTenantTicketTableEvents();
|
||||||
@@ -24,7 +23,6 @@ export default function TicketPage() {
|
|||||||
const ticketsQuery = useTicketsQuery({
|
const ticketsQuery = useTicketsQuery({
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
status,
|
|
||||||
chainageId: segmentId,
|
chainageId: segmentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,11 +30,6 @@ export default function TicketPage() {
|
|||||||
const tickets = ticketsQuery.data?.items ?? [];
|
const tickets = ticketsQuery.data?.items ?? [];
|
||||||
const total = ticketsQuery.data?.total ?? 0;
|
const total = ticketsQuery.data?.total ?? 0;
|
||||||
|
|
||||||
const handleStatusChange = useCallback((nextStatus: TicketStatus | 'all') => {
|
|
||||||
setStatus(nextStatus);
|
|
||||||
setSkip(0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSegmentChange = useCallback((nextSegmentId: string) => {
|
const handleSegmentChange = useCallback((nextSegmentId: string) => {
|
||||||
setSegmentId(nextSegmentId);
|
setSegmentId(nextSegmentId);
|
||||||
setSkip(0);
|
setSkip(0);
|
||||||
@@ -56,13 +49,11 @@ export default function TicketPage() {
|
|||||||
const toolbar = useMemo(
|
const toolbar = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<TicketFilters
|
<TicketFilters
|
||||||
status={status}
|
|
||||||
segmentId={segmentId}
|
segmentId={segmentId}
|
||||||
onStatusChange={handleStatusChange}
|
|
||||||
onSegmentChange={handleSegmentChange}
|
onSegmentChange={handleSegmentChange}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[handleSegmentChange, handleStatusChange, segmentId, status],
|
[handleSegmentChange, segmentId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export const API_ROUTES = {
|
|||||||
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
|
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
|
||||||
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,
|
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,
|
||||||
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,
|
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,
|
||||||
START: (id: string) => `/biz/api/v1/tickets/${id}/start`,
|
|
||||||
SUBMIT_REPAIR: (id: string) => `/biz/api/v1/tickets/${id}/submit-repair`,
|
SUBMIT_REPAIR: (id: string) => `/biz/api/v1/tickets/${id}/submit-repair`,
|
||||||
SUBMIT_REPAIR_UPLOAD: (id: string) =>
|
SUBMIT_REPAIR_UPLOAD: (id: string) =>
|
||||||
`/biz/api/v1/tickets/${id}/repair-proof`,
|
`/biz/api/v1/tickets/${id}/repair-proof`,
|
||||||
|
|||||||
@@ -81,13 +81,6 @@ export const ticketService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
startTicket: async (ticketId: string): Promise<TicketDetail> => {
|
|
||||||
const response = await axiosClient.post<TicketDetail>(
|
|
||||||
API_ROUTES.TICKETS.START(ticketId),
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
submitRepair: async (
|
submitRepair: async (
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
payload: SubmitRepairPayload,
|
payload: SubmitRepairPayload,
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ export interface TicketReviewer extends TicketActor {
|
|||||||
|
|
||||||
export interface TicketAiResult {
|
export interface TicketAiResult {
|
||||||
detection_count: number;
|
detection_count: number;
|
||||||
completed_at: string | null;
|
|
||||||
available_defect_classes: string[];
|
available_defect_classes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,16 +30,3 @@ export type TicketDetailStatusEvent = Partial<TicketDetail> & {
|
|||||||
status?: TicketStatus;
|
status?: TicketStatus;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TicketProgressEvent {
|
|
||||||
type: 'progress' | 'complete';
|
|
||||||
status: string;
|
|
||||||
progress: number;
|
|
||||||
message: string;
|
|
||||||
defect_class?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TicketStreamErrorEvent {
|
|
||||||
type?: 'error';
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export type TicketStatus =
|
export type TicketStatus =
|
||||||
| 'processing'
|
|
||||||
| 'unassigned'
|
| 'unassigned'
|
||||||
| 'assigned'
|
| 'assigned'
|
||||||
| 'under_review'
|
| 'under_review'
|
||||||
|
|||||||
Reference in New Issue
Block a user