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 && (
+
+ )}
+
+
+ );
+}
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