refactor: simplify ticket lifecycle flow, remove processing and completed state

This commit is contained in:
2026-06-30 13:01:08 +05:30
parent adff2e2c41
commit ec77696157
20 changed files with 36 additions and 326 deletions

View File

@@ -88,7 +88,7 @@ export function TicketDefectClassTabs({
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<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}
</Badge>
</CardTitle>

View File

@@ -9,26 +9,19 @@ import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { TicketDetail } from '@/types';
import { TicketStatusBadge } from '../../components/TicketStatusBadge';
export function TicketDetailHeader({ ticket }: { ticket: TicketDetail }) {
const router = useRouter();
const ticketLabel = ticket.ticket_name
const ticketLabel = ticket.ticket_name;
return (
<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="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">
Ticket Detail
</h1>
<TicketStatusBadge status={ticket.status} />
</div>
<h1 className="text-2xl font-semibold tracking-tight">Ticket Detail</h1>
<p className="text-xs ">Ticket: {ticketLabel}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{ticket.video_id && ticket.status !== 'processing' ? (
{ticket.video_id ? (
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<SparkleButton
className="h-10 min-w-36 px-5"

View File

@@ -7,22 +7,19 @@ import { cn } from '@/lib/utils';
import type { TicketDetail, TicketHistoryItem, TicketStatus } from '@/types';
import { formatDate } from '@/utils/date';
import { formatTicketStatus } from '../../components/TicketStatusBadge';
function getActorLabel(item: TicketHistoryItem) {
return item.actor?.name || item.actor?.email || 'System';
}
function getLifecycleTitle(status: TicketStatus) {
const titles: Record<TicketStatus, string> = {
processing: 'Incident Ticket Created',
unassigned: 'Awaiting Assignment',
assigned: 'Dispatched to Maintenance',
under_review: 'Maintenance Finalized',
closed: 'Ticket Closed',
};
return titles[status] ?? formatTicketStatus(status);
return titles[status];
}
function HistoryEntry({
@@ -39,7 +36,7 @@ function HistoryEntry({
{!isLast ? (
<span
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}
@@ -56,7 +53,7 @@ function HistoryEntry({
className={cn(
'size-2 rounded-full bg-primary',
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>

View File

@@ -1,14 +1,7 @@
'use client';
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { TicketActor, TicketDetail } from '@/types';
import { formatDate } from '@/utils/date';
function formatCreatedDate(value: string | null | undefined) {
if (!value) return '-';
@@ -111,12 +104,6 @@ export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
</MetaValue>
</OverviewField>
</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>
);
}

View File

@@ -3,39 +3,24 @@
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { TicketDetail } from '@/types';
import type { TicketDetailLiveState } from '../../hooks/useTicketDetailEvents';
import { AssignTicketAction } from './actions/AssignTicketAction';
import { ClosedTicketAction } from './actions/ClosedTicketAction';
import { NoTicketAction } from './actions/NoTicketAction';
import { ProcessingTicketAction } from './actions/ProcessingTicketAction';
import { ReviewRepairAction } from './actions/ReviewRepairAction';
import { SubmitRepairAction } from './actions/SubmitRepairAction';
import { TicketDefectClassTabs } from './TicketDefectClassTabs';
interface TicketStatusActionsProps {
ticket: TicketDetail;
liveState: TicketDetailLiveState;
}
function getTicketAction(
ticket: TicketDetail,
liveState: TicketDetailLiveState,
) {
if (ticket.status === 'processing') {
return (
<ProcessingTicketAction
progress={liveState.progress}
errorMessage={liveState.errorMessage}
/>
);
}
function getTicketAction(ticket: TicketDetail) {
if (ticket.assignment_status === 'unassigned') {
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={<NoTicketAction status={ticket.status} />}
fallback={<NoTicketAction />}
>
<AssignTicketAction ticket={ticket} />
</PermissionGuard>
@@ -46,7 +31,7 @@ function getTicketAction(
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.WORK}
fallback={<NoTicketAction status={ticket.status} />}
fallback={<NoTicketAction />}
>
<SubmitRepairAction ticket={ticket} />
</PermissionGuard>
@@ -57,7 +42,7 @@ function getTicketAction(
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.REVIEW}
fallback={<NoTicketAction status={ticket.status} />}
fallback={<NoTicketAction />}
>
<ReviewRepairAction ticket={ticket} />
</PermissionGuard>
@@ -68,17 +53,14 @@ function getTicketAction(
return <ClosedTicketAction ticket={ticket} />;
}
return <NoTicketAction status={ticket.status} />;
return <NoTicketAction />;
}
export function TicketStatusActions({
ticket,
liveState,
}: TicketStatusActionsProps) {
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
return (
<div className="space-y-3">
<TicketDefectClassTabs ticketId={ticket.id} />
{getTicketAction(ticket, liveState)}
{getTicketAction(ticket)}
</div>
);
}

View File

@@ -1,37 +1,15 @@
'use client';
import { CircleOff, Clock } from 'lucide-react';
import type { TicketStatus } from '@/types';
import { CircleOff } from 'lucide-react';
import { TicketActionCard } from './TicketActionCard';
const statusConfig: Record<
'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;
export function NoTicketAction() {
return (
<TicketActionCard icon={Icon} title={config.title}>
<p className="text-sm text-muted-foreground">{config.message}</p>
<TicketActionCard icon={CircleOff} title="No Actions">
<p className="text-sm text-muted-foreground">
No action available for this ticket.
</p>
</TicketActionCard>
);
}

View File

@@ -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>
);
}

View File

@@ -24,11 +24,7 @@ export default function TicketDetailPage() {
const ticketQuery = useTicketDetailQuery(ticketId, defectClass);
const ticket = ticketQuery.data;
const liveState = useTicketDetailEvents(
ticketId,
defectClass,
Boolean(ticketId),
);
useTicketDetailEvents(ticketId, defectClass, Boolean(ticketId));
if (ticketQuery.isLoading) {
return <TicketDetailSkeleton />;
@@ -62,7 +58,7 @@ export default function TicketDetailPage() {
</div>
<div className="self-start">
<TicketStatusActions ticket={ticket} liveState={liveState} />
<TicketStatusActions ticket={ticket} />
</div>
</div>
</main>

View File

@@ -5,7 +5,6 @@ import type { ColumnDef } from '@tanstack/react-table';
import type { TicketListItem } from '@/types';
import { TicketStatusBadge } from './TicketStatusBadge';
import { formatDate } from '@/utils/date';
export function useTicketColumns(): ColumnDef<TicketListItem>[] {
@@ -19,12 +18,6 @@ export function useTicketColumns(): ColumnDef<TicketListItem>[] {
<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',
header: 'Segment',

View File

@@ -1,60 +1,19 @@
'use client';
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 {
status: TicketStatus | 'all';
segmentId: string;
onStatusChange: (status: TicketStatus | 'all') => void;
onSegmentChange: (segmentId: string) => void;
}
export function TicketFilters({
status,
segmentId,
onStatusChange,
onSegmentChange,
}: TicketFiltersProps) {
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Select
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>
);
}

View File

@@ -5,7 +5,6 @@ import { cn } from '@/lib/utils';
import type { TicketStatus } from '@/types';
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',
assigned: 'bg-indigo-100 text-indigo-700 border-indigo-200',
under_review: 'bg-violet-100 text-violet-700 border-violet-200',

View File

@@ -39,7 +39,7 @@ export function TicketTable({
isLoading={isLoading}
toolbar={toolbar}
emptyTitle="No tickets found."
emptyDescription="Ticket rows will appear after video processing creates them."
emptyDescription="Tickets will appear when road issues are detected."
pagination={{
skip,
limit,

View File

@@ -1,30 +1,15 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { API_ROUTES } from '@/constants/apiRoutes';
import { useSseWithToken } from '@/hooks/sse/useSseWithToken';
import { ticketService } from '@/services/api';
import type {
TicketDetailStatusEvent,
TicketProgressEvent,
TicketStreamErrorEvent,
} from '@/types';
import type { TicketDetailStatusEvent } from '@/types';
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;
function isHeartbeatEvent(event: { type?: string } | null | undefined) {
@@ -35,14 +20,9 @@ export function useTicketDetailEvents(
ticketId: string | undefined,
defectClass: string | undefined,
enabled = true,
): TicketDetailLiveState {
const router = useRouter();
): void {
const queryClient = useQueryClient();
const defectClassRef = useRef(defectClass);
const [progress, setProgress] = useState<TicketProcessingProgress | null>(
null,
);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
defectClassRef.current = defectClass;
@@ -60,18 +40,6 @@ export function useTicketDetailEvents(
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
}, [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(
() => ticketService.createTicketEventsToken(ticketId as string),
[ticketId],
@@ -89,30 +57,10 @@ export function useTicketDetailEvents(
if (isHeartbeatEvent(event)) return;
refetchTicket();
},
progress: (event: TicketProgressEvent) => {
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...',
);
},
error: () => undefined,
heartbeat: () => undefined,
}),
[refetchTicket, selectDefectClass],
[refetchTicket],
);
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({
enabled: Boolean(enabled && ticketId),
getToken,
@@ -138,8 +77,5 @@ export function useTicketDetailEvents(
events,
reconnectOnEvents: TICKET_DETAIL_RECONNECT_EVENTS,
shouldStop,
onConnectionError,
});
return { progress, errorMessage };
}

View File

@@ -23,29 +23,26 @@ const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
interface UseTicketsQueryParams {
skip: number;
limit: number;
status?: TicketStatus | 'all';
chainageId?: string;
}
function buildTicketListParams({
skip,
limit,
status,
chainageId,
}: UseTicketsQueryParams): TicketListParams {
return {
skip,
limit,
status: status === 'all' ? undefined : status,
chainage_id: chainageId || undefined,
};
}
export function useTicketsQuery(params: UseTicketsQueryParams) {
const { skip, limit, status, chainageId } = params;
const { skip, limit, chainageId } = params;
const listParams = useMemo(
() => buildTicketListParams({ skip, limit, status, chainageId }),
[chainageId, limit, skip, status],
() => buildTicketListParams({ skip, limit, chainageId }),
[chainageId, limit, skip],
);
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) {
const queryClient = useQueryClient();

View File

@@ -4,7 +4,7 @@ import { useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { PageHeader } from '@/components/page-header';
import { Ticket } from 'lucide-react';
import type { TicketListItem, TicketStatus } from '@/types';
import type { TicketListItem } from '@/types';
import { TicketFilters } from './components/TicketFilters';
import { TicketTable } from './components/TicketTable';
@@ -16,7 +16,6 @@ export default function TicketPage() {
const router = useRouter();
const [skip, setSkip] = useState(0);
const [limit, setLimit] = useState(10);
const [status, setStatus] = useState<TicketStatus | 'all'>('all');
const [segmentId, setSegmentId] = useState('');
useTenantTicketTableEvents();
@@ -24,7 +23,6 @@ export default function TicketPage() {
const ticketsQuery = useTicketsQuery({
skip,
limit,
status,
chainageId: segmentId,
});
@@ -32,11 +30,6 @@ export default function TicketPage() {
const tickets = ticketsQuery.data?.items ?? [];
const total = ticketsQuery.data?.total ?? 0;
const handleStatusChange = useCallback((nextStatus: TicketStatus | 'all') => {
setStatus(nextStatus);
setSkip(0);
}, []);
const handleSegmentChange = useCallback((nextSegmentId: string) => {
setSegmentId(nextSegmentId);
setSkip(0);
@@ -56,13 +49,11 @@ export default function TicketPage() {
const toolbar = useMemo(
() => (
<TicketFilters
status={status}
segmentId={segmentId}
onStatusChange={handleStatusChange}
onSegmentChange={handleSegmentChange}
/>
),
[handleSegmentChange, handleStatusChange, segmentId, status],
[handleSegmentChange, segmentId],
);
return (

View File

@@ -59,7 +59,6 @@ export const API_ROUTES = {
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,
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_UPLOAD: (id: string) =>
`/biz/api/v1/tickets/${id}/repair-proof`,

View File

@@ -81,13 +81,6 @@ export const ticketService = {
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 (
ticketId: string,
payload: SubmitRepairPayload,

View File

@@ -52,7 +52,6 @@ export interface TicketReviewer extends TicketActor {
export interface TicketAiResult {
detection_count: number;
completed_at: string | null;
available_defect_classes: string[];
}

View File

@@ -30,16 +30,3 @@ export type TicketDetailStatusEvent = Partial<TicketDetail> & {
status?: TicketStatus;
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;
}

View File

@@ -1,5 +1,4 @@
export type TicketStatus =
| 'processing'
| 'unassigned'
| 'assigned'
| 'under_review'