diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketHistoryCard.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketHistoryCard.tsx index e0573c9..05ccb26 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/TicketHistoryCard.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/TicketHistoryCard.tsx @@ -7,13 +7,7 @@ import type { TicketDetail, TicketHistoryItem, TicketStatus } from '@/types'; import { formatTicketStatus } from '../../components/TicketStatusBadge'; -function formatDate(value: string | null | undefined) { - if (!value) return '-'; - return new Intl.DateTimeFormat('en-IN', { - dateStyle: 'medium', - timeStyle: 'short', - }).format(new Date(value)); -} +import { formatDate } from '@/utils/date'; function getActorLabel(item: TicketHistoryItem) { return item.actor?.name || item.actor?.email || 'System'; diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketOverviewCard.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketOverviewCard.tsx index 1e3ce78..e96d0fc 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/TicketOverviewCard.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/TicketOverviewCard.tsx @@ -5,13 +5,7 @@ import type { TicketActor, TicketDetail } from '@/types'; import { TicketStatusBadge } from '../../components/TicketStatusBadge'; -function formatDate(value: string | null | undefined) { - if (!value) return '-'; - return new Intl.DateTimeFormat('en-IN', { - dateStyle: 'medium', - timeStyle: 'short', - }).format(new Date(value)); -} +import { formatDate } from '@/utils/date'; function OverviewMeta({ label, diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketStatusActions.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketStatusActions.tsx index 2acf037..189467d 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/TicketStatusActions.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/TicketStatusActions.tsx @@ -1,23 +1,65 @@ 'use client'; +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 { NoTicketAction } from './actions/NoTicketAction'; +import { ProcessingTicketAction } from './actions/ProcessingTicketAction'; import { ReviewRepairAction } from './actions/ReviewRepairAction'; import { SubmitRepairAction } from './actions/SubmitRepairAction'; -export function TicketStatusActions({ ticket }: { ticket: TicketDetail }) { +interface TicketStatusActionsProps { + ticket: TicketDetail; + liveState: TicketDetailLiveState; +} + +export function TicketStatusActions({ + ticket, + liveState, +}: TicketStatusActionsProps) { + if (ticket.status === 'processing') { + return ( + + ); + } + if (ticket.status === 'unassigned') { - return ; + return ( + } + > + + + ); } if (ticket.status === 'assigned') { - return ; + return ( + } + > + + + ); } if (ticket.status === 'under_review') { - return ; + return ( + } + > + + + ); } return ; diff --git a/src/app/(modules)/ticket/[ticketId]/components/actions/ProcessingTicketAction.tsx b/src/app/(modules)/ticket/[ticketId]/components/actions/ProcessingTicketAction.tsx new file mode 100644 index 0000000..4df1014 --- /dev/null +++ b/src/app/(modules)/ticket/[ticketId]/components/actions/ProcessingTicketAction.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { AlertCircle, Loader2 } 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 ( + +
+
+
+ + {getProgressMessage(progress)} + + + {hasProgress ? `${Math.round(progressValue)}%` : '--'} + +
+ +
+ + {errorMessage && ( +
+ + {errorMessage} +
+ )} +
+
+ ); +} diff --git a/src/app/(modules)/ticket/[ticketId]/page.tsx b/src/app/(modules)/ticket/[ticketId]/page.tsx index 656bc06..c02e367 100644 --- a/src/app/(modules)/ticket/[ticketId]/page.tsx +++ b/src/app/(modules)/ticket/[ticketId]/page.tsx @@ -10,7 +10,7 @@ import { TicketHistoryCard } from './components/TicketHistoryCard'; import { TicketOverviewCard } from './components/TicketOverviewCard'; import { TicketRepairReportCard } from './components/TicketRepairReportCard'; import { TicketStatusActions } from './components/TicketStatusActions'; -import { useTicketDetailEvents } from '../hooks/useTicketEvents'; +import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents'; import { useTicketDetailQuery } from '../hooks/useTicketQueries'; export default function TicketDetailPage() { @@ -20,7 +20,10 @@ export default function TicketDetailPage() { const ticketQuery = useTicketDetailQuery(ticketId); const ticket = ticketQuery.data; - useTicketDetailEvents(ticketId); + const liveState = useTicketDetailEvents( + ticketId, + Boolean(ticketId && ticket && ticket.status !== 'closed'), + ); if (ticketQuery.isLoading) { return ( @@ -64,7 +67,7 @@ export default function TicketDetailPage() { } /> -
+
@@ -72,7 +75,7 @@ export default function TicketDetailPage() {
- +
diff --git a/src/app/(modules)/ticket/components/TicketColumns.tsx b/src/app/(modules)/ticket/components/TicketColumns.tsx index 645379d..d9ef6e8 100644 --- a/src/app/(modules)/ticket/components/TicketColumns.tsx +++ b/src/app/(modules)/ticket/components/TicketColumns.tsx @@ -6,14 +6,7 @@ import type { ColumnDef } from '@tanstack/react-table'; import type { TicketListItem } from '@/types'; import { TicketStatusBadge } from './TicketStatusBadge'; - -function formatDate(value: string | null | undefined) { - if (!value) return '-'; - return new Intl.DateTimeFormat('en-IN', { - dateStyle: 'medium', - timeStyle: 'short', - }).format(new Date(value)); -} +import { formatDate } from '@/utils/date'; export function useTicketColumns(): ColumnDef[] { return useMemo( @@ -32,21 +25,28 @@ export function useTicketColumns(): ColumnDef[] { size: 150, cell: ({ row }) => , }, + { + accessorKey: 'chainage_name', + header: 'Segment', + size: 180, + cell: ({ row }) => row.original.chainage_name || 'N/A', + }, { accessorKey: 'detection_count', header: 'Detections', size: 120, }, { - accessorKey: 'assigned_to_email', + accessorKey: 'assigned_to_name', header: 'Assigned To', size: 220, - cell: ({ row }) => row.original.assigned_to_email || '-', + cell: ({ row }) => row.original.assigned_to_name || 'N/A', }, { - accessorKey: 'created_by_email', + accessorKey: 'created_by_name', header: 'Uploaded By', size: 220, + cell: ({ row }) => row.original.created_by_name || 'N/A', }, { accessorKey: 'updated_at', diff --git a/src/app/(modules)/ticket/components/TicketTable.tsx b/src/app/(modules)/ticket/components/TicketTable.tsx index 2e4856e..2c14568 100644 --- a/src/app/(modules)/ticket/components/TicketTable.tsx +++ b/src/app/(modules)/ticket/components/TicketTable.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react'; import type { ColumnDef } from '@tanstack/react-table'; -import { BarChart3, Eye, ChartPie } from 'lucide-react'; +import { Eye } from 'lucide-react'; import { DataTable } from '@/components/data-table'; import type { TicketListItem } from '@/types'; @@ -18,7 +18,6 @@ interface TicketTableProps { onPageChange: (skip: number) => void; onLimitChange: (limit: number) => void; onView: (ticket: TicketListItem) => void; - onShowAnalytics: (ticket: TicketListItem) => void; } export function TicketTable({ @@ -32,7 +31,6 @@ export function TicketTable({ onPageChange, onLimitChange, onView, - onShowAnalytics, }: TicketTableProps) { return ( , onClick: onView, }, - { - label: 'Show Analytics', - icon: , - onClick: onShowAnalytics, - }, ]} pagination={{ skip, diff --git a/src/app/(modules)/ticket/hooks/useTenantTicketTableEvents.ts b/src/app/(modules)/ticket/hooks/useTenantTicketTableEvents.ts new file mode 100644 index 0000000..a527b29 --- /dev/null +++ b/src/app/(modules)/ticket/hooks/useTenantTicketTableEvents.ts @@ -0,0 +1,167 @@ +'use client'; + +import { useCallback, useMemo } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { API_ROUTES } from '@/constants/apiRoutes'; +import { useSseWithToken } from '@/hooks/sse/useSseWithToken'; +import { ticketService } from '@/services/api'; +import type { + TicketListItem, + TicketListParams, + TicketListResponse, + TicketTableStatusEvent, +} from '@/types'; + +import { ticketKeys } from '../queries/ticketKeys'; + +function patchTicketLists( + current: TicketListResponse | undefined, + event: TicketTableStatusEvent, + params?: TicketListParams, +) { + if (!current || !event.id) return current; + + const nextRow = buildTicketListItem(event); + const existingIndex = current.items.findIndex( + (ticket) => ticket.id === event.id, + ); + + if (existingIndex >= 0) { + return { + ...current, + items: current.items.map((ticket) => + ticket.id === event.id ? mergeTicketListItem(ticket, event) : ticket, + ), + }; + } + + if (event.kind !== 'created') return current; + if (!shouldCountCreatedTicket(event, params)) return current; + + if (!nextRow || !isFirstPage(params)) { + return { + ...current, + total: current.total + 1, + }; + } + + const pageLimit = params?.limit ?? current.items.length + 1; + + return { + ...current, + items: [nextRow, ...current.items].slice(0, pageLimit), + total: current.total + 1, + }; +} + +function shouldCountCreatedTicket( + event: TicketTableStatusEvent, + params?: TicketListParams, +) { + if (params?.status && event.status !== params.status) return false; + if (params?.chainage_id && event.chainage_id !== params.chainage_id) { + return false; + } + + return true; +} + +function isFirstPage(params?: TicketListParams) { + return (params?.skip ?? 0) === 0; +} + +function getTicketListParams(queryKey: readonly unknown[]) { + const params = queryKey[2]; + + if (!params || typeof params !== 'object' || Array.isArray(params)) { + return undefined; + } + + return params as TicketListParams; +} + +function buildTicketListItem( + event: TicketTableStatusEvent, +): TicketListItem | null { + if ( + !event.id || + !event.status || + event.detection_count === undefined || + !event.updated_at + ) { + return null; + } + + return { + id: event.id, + chainage_id: event.chainage_id ?? null, + chainage_name: event.chainage_name ?? null, + status: event.status, + assigned_to_name: event.assigned_to_name ?? null, + detection_count: event.detection_count, + created_by_name: event.created_by_name ?? null, + created_at: event.created_at ?? event.updated_at, + updated_at: event.updated_at, + }; +} + +function mergeTicketListItem( + current: TicketListItem, + event: TicketTableStatusEvent, +): TicketListItem { + return { + ...current, + chainage_id: event.chainage_id ?? current.chainage_id, + chainage_name: event.chainage_name ?? current.chainage_name, + status: event.status ?? current.status, + assigned_to_name: event.assigned_to_name ?? current.assigned_to_name, + detection_count: event.detection_count ?? current.detection_count, + created_by_name: event.created_by_name ?? current.created_by_name, + created_at: event.created_at ?? current.created_at, + updated_at: event.updated_at ?? current.updated_at, + }; +} + +export function useTenantTicketTableEvents(enabled = true) { + const queryClient = useQueryClient(); + + const events = useMemo( + () => ({ + ticket_status: (event: TicketTableStatusEvent) => { + queryClient + .getQueryCache() + .findAll({ queryKey: ticketKeys.lists() }) + .forEach((query) => { + queryClient.setQueryData( + query.queryKey, + (current) => + patchTicketLists( + current, + event, + getTicketListParams(query.queryKey), + ), + ); + }); + }, + }), + [queryClient], + ); + + const getPath = useCallback( + (token: string) => API_ROUTES.TICKET_EVENTS.TENANT(token), + [], + ); + + const onConnectionError = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); + }, [queryClient]); + + useSseWithToken({ + enabled, + getToken: ticketService.createTenantTicketEventsToken, + getPath, + events, + onConnectionError, + }); +} diff --git a/src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts b/src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts new file mode 100644 index 0000000..baf0510 --- /dev/null +++ b/src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts @@ -0,0 +1,119 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +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 { 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) { + return event?.type === 'heartbeat'; +} + +export function useTicketDetailEvents( + ticketId: string | undefined, + enabled = true, +): TicketDetailLiveState { + const queryClient = useQueryClient(); + const [progress, setProgress] = useState( + null, + ); + const [errorMessage, setErrorMessage] = useState(null); + + const refetchTicket = useCallback(() => { + if (!ticketId) return; + + queryClient.refetchQueries({ queryKey: ticketKeys.detail(ticketId) }); + queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); + }, [queryClient, ticketId]); + + const getToken = useCallback( + () => ticketService.createTicketEventsToken(ticketId as string), + [ticketId], + ); + + const getPath = useCallback( + (token: string) => + API_ROUTES.TICKETS.DETAIL_EVENTS(ticketId as string, token), + [ticketId], + ); + + const events = useMemo( + () => ({ + ticket_status: (event: TicketDetailStatusEvent) => { + 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, + }); + refetchTicket(); + }, + error: (event: TicketStreamErrorEvent) => { + setErrorMessage( + event.message ?? 'Live ticket updates disconnected. Reconnecting...', + ); + }, + heartbeat: () => undefined, + }), + [refetchTicket], + ); + + const shouldStop = useCallback( + (eventName: keyof typeof events, event: unknown) => + eventName === 'ticket_status' && + (event as TicketDetailStatusEvent).status === 'closed', + [], + ); + + const onConnectionError = useCallback(() => { + setErrorMessage('Live ticket updates disconnected. Reconnecting...'); + }, []); + + useEffect(() => { + setProgress(null); + setErrorMessage(null); + }, [ticketId]); + + useSseWithToken({ + enabled: Boolean(enabled && ticketId), + getToken, + getPath, + events, + reconnectOnEvents: TICKET_DETAIL_RECONNECT_EVENTS, + shouldStop, + onConnectionError, + }); + + return { progress, errorMessage }; +} diff --git a/src/app/(modules)/ticket/hooks/useTicketEvents.ts b/src/app/(modules)/ticket/hooks/useTicketEvents.ts deleted file mode 100644 index 9d87e8b..0000000 --- a/src/app/(modules)/ticket/hooks/useTicketEvents.ts +++ /dev/null @@ -1,159 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; - -import { API_ROUTES } from '@/constants/apiRoutes'; -import { ticketService } from '@/services/api'; -import { sseService } from '@/services/sse'; -import type { - TicketDetail, - TicketListResponse, - TicketProgressEvent, - TicketStatusEvent, -} from '@/types'; - -import { ticketKeys } from '../queries/ticketKeys'; - -function getTicketEventId(event: TicketStatusEvent) { - return event.id ?? event.ticket_id; -} - -function mergeTicketDetail( - current: TicketDetail | undefined, - event: TicketStatusEvent, -) { - if (!current) return current; - - const eventId = getTicketEventId(event); - if (eventId && eventId !== current.id) return current; - - return { - ...current, - ...event, - id: current.id, - history: event.history ?? current.history, - }; -} - -function patchTicketLists( - current: TicketListResponse | undefined, - event: TicketStatusEvent, -) { - const eventId = getTicketEventId(event); - if (!current || !eventId) return current; - - return { - ...current, - items: current.items.map((ticket) => - ticket.id === eventId - ? { - ...ticket, - status: event.status ?? ticket.status, - updated_at: event.timestamps?.updated_at ?? ticket.updated_at, - chainage_id: event.chainage_id ?? ticket.chainage_id, - video_id: event.video_id ?? ticket.video_id, - } - : ticket, - ), - }; -} - -export function useTenantTicketTableEvents(enabled = true) { - const queryClient = useQueryClient(); - - useEffect(() => { - if (!enabled) return; - - let closed = false; - let closeConnection: (() => void) | undefined; - - ticketService - .createTenantTicketEventsToken() - .then(({ sse_token }) => { - if (closed) return; - - const connection = sseService.connect( - API_ROUTES.TICKET_EVENTS.TENANT(sse_token), - { - ticket_status: (event: TicketStatusEvent) => { - queryClient.setQueriesData( - { queryKey: ticketKeys.lists() }, - (current) => patchTicketLists(current, event), - ); - }, - }, - ); - - closeConnection = connection.close; - }) - .catch(() => { - queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); - }); - - return () => { - closed = true; - closeConnection?.(); - }; - }, [enabled, queryClient]); -} - -export function useTicketDetailEvents( - ticketId: string | undefined, - enabled = true, -) { - const queryClient = useQueryClient(); - - useEffect(() => { - if (!enabled || !ticketId) return; - - let closed = false; - let closeConnection: (() => void) | undefined; - - ticketService - .createTicketEventsToken(ticketId) - .then(({ sse_token }) => { - if (closed) return; - - const connection = sseService.connect( - API_ROUTES.TICKETS.DETAIL_EVENTS(ticketId, sse_token), - { - ticket_status: (event: TicketStatusEvent) => { - queryClient.setQueryData( - ticketKeys.detail(ticketId), - (current) => mergeTicketDetail(current, event), - ); - queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); - }, - progress: (event: TicketProgressEvent) => { - queryClient.setQueryData( - ticketKeys.detail(ticketId), - (current) => - current && event.status - ? { ...current, status: current.status } - : current, - ); - }, - complete: () => { - queryClient.invalidateQueries({ - queryKey: ticketKeys.detail(ticketId), - }); - queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); - }, - }, - ); - - closeConnection = connection.close; - }) - .catch(() => { - queryClient.invalidateQueries({ - queryKey: ticketKeys.detail(ticketId), - }); - }); - - return () => { - closed = true; - closeConnection?.(); - }; - }, [enabled, queryClient, ticketId]); -} diff --git a/src/app/(modules)/ticket/hooks/useTicketQueries.ts b/src/app/(modules)/ticket/hooks/useTicketQueries.ts index f13f830..d53101a 100644 --- a/src/app/(modules)/ticket/hooks/useTicketQueries.ts +++ b/src/app/(modules)/ticket/hooks/useTicketQueries.ts @@ -17,6 +17,8 @@ import type { import { ticketKeys } from '../queries/ticketKeys'; +const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000; + interface UseTicketsQueryParams { skip: number; limit: number; @@ -48,6 +50,8 @@ export function useTicketsQuery(params: UseTicketsQueryParams) { return useQuery({ queryKey: ticketKeys.list(listParams), queryFn: () => ticketService.getTickets(listParams), + staleTime: TICKET_TABLE_REFRESH_MS, + refetchInterval: TICKET_TABLE_REFRESH_MS, }); } diff --git a/src/app/(modules)/ticket/page.tsx b/src/app/(modules)/ticket/page.tsx index 3e2c156..2259cfc 100644 --- a/src/app/(modules)/ticket/page.tsx +++ b/src/app/(modules)/ticket/page.tsx @@ -9,7 +9,7 @@ import type { TicketListItem, TicketStatus } from '@/types'; import { TicketFilters } from './components/TicketFilters'; import { TicketTable } from './components/TicketTable'; import { useTicketColumns } from './components/TicketColumns'; -import { useTenantTicketTableEvents } from './hooks/useTicketEvents'; +import { useTenantTicketTableEvents } from './hooks/useTenantTicketTableEvents'; import { useTicketsQuery } from './hooks/useTicketQueries'; export default function TicketPage() { @@ -49,13 +49,6 @@ export default function TicketPage() { [router], ); - const handleShowAnalytics = useCallback( - (ticket: TicketListItem) => { - router.push(`/results/${ticket.video_id}`); - }, - [router], - ); - const toolbar = useMemo( () => ( ); diff --git a/src/config/app.routes.ts b/src/config/app.routes.ts index 14158a4..97713ec 100644 --- a/src/config/app.routes.ts +++ b/src/config/app.routes.ts @@ -4,6 +4,7 @@ import { ROUTES } from '@/utils/routes'; export type AppRoute = { path: string; permission?: string; + match?: 'exact' | 'prefix'; }; export const appRoutes: AppRoute[] = [ @@ -23,8 +24,21 @@ export const appRoutes: AppRoute[] = [ path: ROUTES.PLANS, permission: PERMISSIONS.PLAN.READ, }, + { + path: ROUTES.UPLOAD, + permission: PERMISSIONS.VIDEO.UPLOAD, + }, + { + path: ROUTES.TICKET, + permission: PERMISSIONS.TICKET.READ, + match: 'prefix', + }, ]; export function getRoutePermission(pathname: string) { - return appRoutes.find((route) => route.path === pathname)?.permission; + return appRoutes.find((route) => + route.match === 'prefix' + ? pathname === route.path || pathname.startsWith(`${route.path}/`) + : route.path === pathname, + )?.permission; } diff --git a/src/config/menu.config.ts b/src/config/menu.config.ts index 0793ed8..f9bf081 100644 --- a/src/config/menu.config.ts +++ b/src/config/menu.config.ts @@ -35,11 +35,13 @@ export const menuItems: MenuItem[] = [ title: 'Upload', path: ROUTES.UPLOAD, icon: Plus, + permission: PERMISSIONS.VIDEO.UPLOAD, }, { title: 'Ticket', path: ROUTES.TICKET, icon: Ticket, + permission: PERMISSIONS.TICKET.READ, }, { title: 'Project', diff --git a/src/constants/permissions.ts b/src/constants/permissions.ts index 9e99f4a..202c3c4 100644 --- a/src/constants/permissions.ts +++ b/src/constants/permissions.ts @@ -29,7 +29,14 @@ export const PERMISSIONS = { UPDATE: 'administration.tenants.update', DELETE: 'administration.tenants.delete', }, - LOG: { - VIEW: 'log.view', + TICKET: { + ASSIGN: 'ticket.assign', + WORK: 'ticket.work', + REVIEW: 'ticket.review', + READ: 'ticket.read', + CLOSE: 'ticket.close', + }, + VIDEO: { + UPLOAD: 'video.upload', }, } as const; diff --git a/src/hooks/sse/useSseWithToken.ts b/src/hooks/sse/useSseWithToken.ts new file mode 100644 index 0000000..b75fe4a --- /dev/null +++ b/src/hooks/sse/useSseWithToken.ts @@ -0,0 +1,142 @@ +'use client'; + +import { useCallback, useEffect, useRef } from 'react'; + +import { + sseService, + type SseConnection, + type SseEventMap, +} from '@/services/sse'; + +const DEFAULT_RETRY_DELAY_MS = 2500; +const EMPTY_RECONNECT_EVENTS: ReadonlyArray = []; + +interface SseToken { + sse_token: string; +} + +interface UseSseWithTokenOptions> { + enabled?: boolean; + getToken: () => Promise; + getPath: (token: string) => string; + events: SseEventMap; + retryDelayMs?: number; + onConnectionError?: () => void; + reconnectOnEvents?: ReadonlyArray; + shouldStop?: ( + eventName: keyof TEvents, + event: TEvents[keyof TEvents], + ) => boolean; +} + +export function useSseWithToken>({ + enabled = true, + getToken, + getPath, + events, + retryDelayMs = DEFAULT_RETRY_DELAY_MS, + onConnectionError, + reconnectOnEvents = EMPTY_RECONNECT_EVENTS as ReadonlyArray, + shouldStop, +}: UseSseWithTokenOptions) { + const connectionRef = useRef(null); + const retryTimerRef = useRef | null>(null); + const stoppedRef = useRef(false); + + const clearRetryTimer = useCallback(() => { + if (!retryTimerRef.current) return; + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + }, []); + + const closeConnection = useCallback(() => { + connectionRef.current?.close(); + connectionRef.current = null; + }, []); + + const stop = useCallback(() => { + stoppedRef.current = true; + clearRetryTimer(); + closeConnection(); + }, [clearRetryTimer, closeConnection]); + + useEffect(() => { + if (!enabled) return; + + stoppedRef.current = false; + + const scheduleReconnect = (notify = true) => { + if (stoppedRef.current || retryTimerRef.current) return; + + closeConnection(); + if (notify) onConnectionError?.(); + retryTimerRef.current = setTimeout(() => { + retryTimerRef.current = null; + connect(); + }, retryDelayMs); + }; + + const connect = () => { + if (stoppedRef.current) return; + + getToken() + .then(({ sse_token }) => { + if (stoppedRef.current) return; + + const wrappedEvents = Object.fromEntries( + Object.entries(events).map(([eventName, handler]) => [ + eventName, + (event: unknown, messageEvent: MessageEvent) => { + ( + handler as ( + event: unknown, + messageEvent: MessageEvent, + ) => void + )(event, messageEvent); + + if ( + shouldStop?.( + eventName as keyof TEvents, + event as TEvents[keyof TEvents], + ) + ) { + stop(); + return; + } + + if (reconnectOnEvents.includes(eventName as keyof TEvents)) { + scheduleReconnect(false); + } + }, + ]), + ) as SseEventMap; + + const connection = sseService.connect( + getPath(sse_token), + wrappedEvents, + ); + + connection.source.onerror = () => scheduleReconnect(); + connectionRef.current = connection; + }) + .catch(() => scheduleReconnect()); + }; + + connect(); + + return stop; + }, [ + closeConnection, + enabled, + events, + getPath, + getToken, + onConnectionError, + reconnectOnEvents, + retryDelayMs, + shouldStop, + stop, + ]); + + return { stop }; +} diff --git a/src/types/ticket/actions.ts b/src/types/ticket/actions.ts new file mode 100644 index 0000000..c10e381 --- /dev/null +++ b/src/types/ticket/actions.ts @@ -0,0 +1,39 @@ +import type { PaginationParams } from '../common'; + +export interface AssignableTicketUser { + id: number; + name: string; + email: string; + username: string; +} + +export interface AssignableTicketUsersParams extends PaginationParams { + search_term?: string; +} + +export interface AssignableTicketUsersResponse { + items: AssignableTicketUser[]; + total: number; +} + +export interface AssignTicketPayload { + assigned_to_user_id: number; + assigned_to_email?: string; + note?: string; +} + +export interface SubmitRepairPayload { + notes: string; + proof_path: string; +} + +export interface SubmitRepairUploadPayload { + notes: string; + video: File; + images: File[]; +} + +export interface ReviewRepairPayload { + action: 'approve' | 'reject'; + comment: string; +} diff --git a/src/types/ticket.ts b/src/types/ticket/detail.ts similarity index 50% rename from src/types/ticket.ts rename to src/types/ticket/detail.ts index 6625ac8..0f2d7ba 100644 --- a/src/types/ticket.ts +++ b/src/types/ticket/detail.ts @@ -1,29 +1,9 @@ -import type { PaginationParams } from './common'; +import type { TicketStatus } from './status'; -export type TicketStatus = - | 'processing' - | 'unassigned' - | 'assigned' - | 'under_review' - | 'closed'; - -export interface TicketListParams extends PaginationParams { - status?: TicketStatus; - chainage_id?: string; -} - -export interface TicketListItem { - id: string; - video_id: string; - chainage_id: string; - status: TicketStatus; - assigned_to_user_id: number | null; - assigned_to_email: string | null; - detection_count: number; - created_by_user_id: number; - created_by_email: string; - created_at: string; - updated_at: string; +export interface TicketActor { + user_id: number | null; + name: string | null; + email: string | null; } export interface TicketHistoryItem { @@ -35,12 +15,6 @@ export interface TicketHistoryItem { created_at: string; } -export interface TicketActor { - user_id: number | null; - name: string | null; - email: string | null; -} - export interface TicketStatusMetadata { reason: string | null; label: string | null; @@ -105,66 +79,3 @@ export interface TicketDetail { timestamps: TicketTimestamps; history: TicketHistoryItem[]; } - -export interface TicketListResponse { - items: TicketListItem[]; - total: number; -} - -export interface AssignableTicketUser { - id: number; - name: string; - email: string; - username: string; -} - -export interface AssignableTicketUsersParams extends PaginationParams { - search_term?: string; -} - -export interface AssignableTicketUsersResponse { - items: AssignableTicketUser[]; - total: number; -} - -export interface AssignTicketPayload { - assigned_to_user_id: number; - assigned_to_email?: string; - note?: string; -} - -export interface SubmitRepairPayload { - notes: string; - proof_path: string; -} - -export interface SubmitRepairUploadPayload { - notes: string; - video: File; - images: File[]; -} - -export interface ReviewRepairPayload { - action: 'approve' | 'reject'; - comment: string; -} - -export interface SseTokenResponse { - sse_token: string; - expires_in: number; -} - -export type TicketStatusEvent = Partial & { - id?: string; - ticket_id?: string; - status?: TicketStatus; -}; - -export interface TicketProgressEvent { - ticket_id?: string; - video_id?: string; - progress?: number; - percent?: number; - message?: string; - status?: string; -} diff --git a/src/types/ticket/events.ts b/src/types/ticket/events.ts new file mode 100644 index 0000000..a16bab5 --- /dev/null +++ b/src/types/ticket/events.ts @@ -0,0 +1,42 @@ +import type { TicketDetail } from './detail'; +import type { TicketStatus } from './status'; + +export interface SseTokenResponse { + sse_token: string; + expires_in: number; +} + +export type TicketTableStatusEvent = { + type?: 'ticket_status'; + kind?: 'created' | 'transitioned'; + id: string; + chainage_id?: string | null; + chainage_name?: string | null; + status?: TicketStatus; + assigned_to_name?: string | null; + detection_count?: number; + created_by_name?: string | null; + created_at?: string; + updated_at?: string; + message?: string; +}; + +export type TicketDetailStatusEvent = Partial & { + type: 'ticket_status' | 'heartbeat'; + kind?: 'snapshot' | 'transitioned'; + id?: string; + status?: TicketStatus; + message?: string; +}; + +export interface TicketProgressEvent { + type: 'progress' | 'complete'; + status: string; + progress: number; + message: string; +} + +export interface TicketStreamErrorEvent { + type?: 'error'; + message?: string; +} diff --git a/src/types/ticket/index.ts b/src/types/ticket/index.ts new file mode 100644 index 0000000..d161645 --- /dev/null +++ b/src/types/ticket/index.ts @@ -0,0 +1,5 @@ +export * from './status'; +export * from './list'; +export * from './detail'; +export * from './actions'; +export * from './events'; diff --git a/src/types/ticket/list.ts b/src/types/ticket/list.ts new file mode 100644 index 0000000..4b2ad4d --- /dev/null +++ b/src/types/ticket/list.ts @@ -0,0 +1,24 @@ +import type { PaginationParams } from '../common'; +import type { TicketStatus } from './status'; + +export interface TicketListParams extends PaginationParams { + status?: TicketStatus; + chainage_id?: string; +} + +export interface TicketListItem { + id: string; + chainage_id: string | null; + chainage_name: string | null; + status: TicketStatus; + assigned_to_name: string | null; + detection_count: number; + created_by_name: string | null; + created_at: string; + updated_at: string; +} + +export interface TicketListResponse { + items: TicketListItem[]; + total: number; +} diff --git a/src/types/ticket/status.ts b/src/types/ticket/status.ts new file mode 100644 index 0000000..0048567 --- /dev/null +++ b/src/types/ticket/status.ts @@ -0,0 +1,6 @@ +export type TicketStatus = + | 'processing' + | 'unassigned' + | 'assigned' + | 'under_review' + | 'closed'; diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 0000000..11bfffa --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,7 @@ +export function formatDate(value: string | null | undefined) { + if (!value) return '-'; + return new Intl.DateTimeFormat('en-IN', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)); +} \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..98adf9e --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './routes'; +export * from './date'; \ No newline at end of file