2 Commits

Author SHA1 Message Date
664d80809a refactor: modify route guard pattern 2026-06-19 18:36:05 +05:30
fffd8e3e12 chore: ticket section implement sse 2026-06-19 18:06:11 +05:30
39 changed files with 920 additions and 352 deletions

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,16 @@
'use client';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionRouteGuard } from '@/guards';
export default function ClientsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PermissionRouteGuard permission={PERMISSIONS.CLIENT.READ}>
{children}
</PermissionRouteGuard>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionRouteGuard } from '@/guards';
export default function PackageLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PermissionRouteGuard permission={PERMISSIONS.PACKAGE.READ}>
{children}
</PermissionRouteGuard>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionRouteGuard } from '@/guards';
export default function PlansLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PermissionRouteGuard permission={PERMISSIONS.PLAN.READ}>
{children}
</PermissionRouteGuard>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionRouteGuard } from '@/guards';
export default function ProjectLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PermissionRouteGuard permission={PERMISSIONS.PROJECT.READ}>
{children}
</PermissionRouteGuard>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionRouteGuard } from '@/guards';
export default function RolesLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PermissionRouteGuard permission={PERMISSIONS.ROLE.READ}>
{children}
</PermissionRouteGuard>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionRouteGuard } from '@/guards';
export default function SegmentLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PermissionRouteGuard permission={PERMISSIONS.SEGMENT.READ}>
{children}
</PermissionRouteGuard>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionRouteGuard } from '@/guards';
export default function TenantsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PermissionRouteGuard permission={PERMISSIONS.TENANT.READ}>
{children}
</PermissionRouteGuard>
);
}

View File

@@ -7,13 +7,7 @@ import type { TicketDetail, TicketHistoryItem, TicketStatus } from '@/types';
import { formatTicketStatus } from '../../components/TicketStatusBadge'; import { formatTicketStatus } from '../../components/TicketStatusBadge';
function formatDate(value: string | null | undefined) { import { formatDate } from '@/utils/date';
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function getActorLabel(item: TicketHistoryItem) { function getActorLabel(item: TicketHistoryItem) {
return item.actor?.name || item.actor?.email || 'System'; return item.actor?.name || item.actor?.email || 'System';

View File

@@ -5,13 +5,7 @@ import type { TicketActor, TicketDetail } from '@/types';
import { TicketStatusBadge } from '../../components/TicketStatusBadge'; import { TicketStatusBadge } from '../../components/TicketStatusBadge';
function formatDate(value: string | null | undefined) { import { formatDate } from '@/utils/date';
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function OverviewMeta({ function OverviewMeta({
label, label,

View File

@@ -1,23 +1,65 @@
'use client'; 'use client';
import { PERMISSIONS } from '@/constants/permissions';
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 { 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';
export function TicketStatusActions({ ticket }: { ticket: TicketDetail }) { interface TicketStatusActionsProps {
ticket: TicketDetail;
liveState: TicketDetailLiveState;
}
export function TicketStatusActions({
ticket,
liveState,
}: TicketStatusActionsProps) {
if (ticket.status === 'processing') {
return (
<ProcessingTicketAction
progress={liveState.progress}
errorMessage={liveState.errorMessage}
/>
);
}
if (ticket.status === 'unassigned') { if (ticket.status === 'unassigned') {
return <AssignTicketAction ticket={ticket} />; return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={<NoTicketAction status={ticket.status} />}
>
<AssignTicketAction ticket={ticket} />
</PermissionGuard>
);
} }
if (ticket.status === 'assigned') { if (ticket.status === 'assigned') {
return <SubmitRepairAction ticket={ticket} />; return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.WORK}
fallback={<NoTicketAction status={ticket.status} />}
>
<SubmitRepairAction ticket={ticket} />
</PermissionGuard>
);
} }
if (ticket.status === 'under_review') { if (ticket.status === 'under_review') {
return <ReviewRepairAction ticket={ticket} />; return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.REVIEW}
fallback={<NoTicketAction status={ticket.status} />}
>
<ReviewRepairAction ticket={ticket} />
</PermissionGuard>
);
} }
return <NoTicketAction status={ticket.status} />; return <NoTicketAction status={ticket.status} />;

View File

@@ -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 (
<TicketActionCard icon={Loader2} 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

@@ -10,7 +10,7 @@ import { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketOverviewCard } from './components/TicketOverviewCard'; import { TicketOverviewCard } from './components/TicketOverviewCard';
import { TicketRepairReportCard } from './components/TicketRepairReportCard'; import { TicketRepairReportCard } from './components/TicketRepairReportCard';
import { TicketStatusActions } from './components/TicketStatusActions'; import { TicketStatusActions } from './components/TicketStatusActions';
import { useTicketDetailEvents } from '../hooks/useTicketEvents'; import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
import { useTicketDetailQuery } from '../hooks/useTicketQueries'; import { useTicketDetailQuery } from '../hooks/useTicketQueries';
export default function TicketDetailPage() { export default function TicketDetailPage() {
@@ -20,7 +20,10 @@ export default function TicketDetailPage() {
const ticketQuery = useTicketDetailQuery(ticketId); const ticketQuery = useTicketDetailQuery(ticketId);
const ticket = ticketQuery.data; const ticket = ticketQuery.data;
useTicketDetailEvents(ticketId); const liveState = useTicketDetailEvents(
ticketId,
Boolean(ticketId && ticket && ticket.status !== 'closed'),
);
if (ticketQuery.isLoading) { if (ticketQuery.isLoading) {
return ( return (
@@ -64,7 +67,7 @@ export default function TicketDetailPage() {
} }
/> />
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_380px]"> <div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_480px]">
<div className="space-y-5"> <div className="space-y-5">
<TicketOverviewCard ticket={ticket} /> <TicketOverviewCard ticket={ticket} />
<TicketRepairReportCard ticket={ticket} /> <TicketRepairReportCard ticket={ticket} />
@@ -72,7 +75,7 @@ export default function TicketDetailPage() {
</div> </div>
<div> <div>
<TicketStatusActions ticket={ticket} /> <TicketStatusActions ticket={ticket} liveState={liveState} />
</div> </div>
</div> </div>
</main> </main>

View File

@@ -6,14 +6,7 @@ import type { ColumnDef } from '@tanstack/react-table';
import type { TicketListItem } from '@/types'; import type { TicketListItem } from '@/types';
import { TicketStatusBadge } from './TicketStatusBadge'; import { TicketStatusBadge } from './TicketStatusBadge';
import { formatDate } from '@/utils/date';
function formatDate(value: string | null | undefined) {
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
export function useTicketColumns(): ColumnDef<TicketListItem>[] { export function useTicketColumns(): ColumnDef<TicketListItem>[] {
return useMemo( return useMemo(
@@ -32,21 +25,28 @@ export function useTicketColumns(): ColumnDef<TicketListItem>[] {
size: 150, size: 150,
cell: ({ row }) => <TicketStatusBadge status={row.original.status} />, cell: ({ row }) => <TicketStatusBadge status={row.original.status} />,
}, },
{
accessorKey: 'chainage_name',
header: 'Segment',
size: 180,
cell: ({ row }) => row.original.chainage_name || 'N/A',
},
{ {
accessorKey: 'detection_count', accessorKey: 'detection_count',
header: 'Detections', header: 'Detections',
size: 120, size: 120,
}, },
{ {
accessorKey: 'assigned_to_email', accessorKey: 'assigned_to_name',
header: 'Assigned To', header: 'Assigned To',
size: 220, 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', header: 'Uploaded By',
size: 220, size: 220,
cell: ({ row }) => row.original.created_by_name || 'N/A',
}, },
{ {
accessorKey: 'updated_at', accessorKey: 'updated_at',

View File

@@ -2,7 +2,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; 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 { DataTable } from '@/components/data-table';
import type { TicketListItem } from '@/types'; import type { TicketListItem } from '@/types';
@@ -18,7 +18,6 @@ interface TicketTableProps {
onPageChange: (skip: number) => void; onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void; onLimitChange: (limit: number) => void;
onView: (ticket: TicketListItem) => void; onView: (ticket: TicketListItem) => void;
onShowAnalytics: (ticket: TicketListItem) => void;
} }
export function TicketTable({ export function TicketTable({
@@ -32,7 +31,6 @@ export function TicketTable({
onPageChange, onPageChange,
onLimitChange, onLimitChange,
onView, onView,
onShowAnalytics,
}: TicketTableProps) { }: TicketTableProps) {
return ( return (
<DataTable <DataTable
@@ -49,11 +47,6 @@ export function TicketTable({
icon: <Eye className="size-4" />, icon: <Eye className="size-4" />,
onClick: onView, onClick: onView,
}, },
{
label: 'Show Analytics',
icon: <ChartPie className="size-4" />,
onClick: onShowAnalytics,
},
]} ]}
pagination={{ pagination={{
skip, skip,

View File

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

View File

@@ -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<TicketProcessingProgress | null>(
null,
);
const [errorMessage, setErrorMessage] = useState<string | null>(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 };
}

View File

@@ -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<TicketListResponse>(
{ 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<TicketDetail>(
ticketKeys.detail(ticketId),
(current) => mergeTicketDetail(current, event),
);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
progress: (event: TicketProgressEvent) => {
queryClient.setQueryData<TicketDetail>(
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]);
}

View File

@@ -17,6 +17,8 @@ import type {
import { ticketKeys } from '../queries/ticketKeys'; import { ticketKeys } from '../queries/ticketKeys';
const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
interface UseTicketsQueryParams { interface UseTicketsQueryParams {
skip: number; skip: number;
limit: number; limit: number;
@@ -48,6 +50,8 @@ export function useTicketsQuery(params: UseTicketsQueryParams) {
return useQuery({ return useQuery({
queryKey: ticketKeys.list(listParams), queryKey: ticketKeys.list(listParams),
queryFn: () => ticketService.getTickets(listParams), queryFn: () => ticketService.getTickets(listParams),
staleTime: TICKET_TABLE_REFRESH_MS,
refetchInterval: TICKET_TABLE_REFRESH_MS,
}); });
} }

View File

@@ -0,0 +1,16 @@
'use client';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionRouteGuard } from '@/guards';
export default function TicketLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PermissionRouteGuard permission={PERMISSIONS.TICKET.READ}>
{children}
</PermissionRouteGuard>
);
}

View File

@@ -9,7 +9,7 @@ import type { TicketListItem, TicketStatus } from '@/types';
import { TicketFilters } from './components/TicketFilters'; import { TicketFilters } from './components/TicketFilters';
import { TicketTable } from './components/TicketTable'; import { TicketTable } from './components/TicketTable';
import { useTicketColumns } from './components/TicketColumns'; import { useTicketColumns } from './components/TicketColumns';
import { useTenantTicketTableEvents } from './hooks/useTicketEvents'; import { useTenantTicketTableEvents } from './hooks/useTenantTicketTableEvents';
import { useTicketsQuery } from './hooks/useTicketQueries'; import { useTicketsQuery } from './hooks/useTicketQueries';
export default function TicketPage() { export default function TicketPage() {
@@ -49,13 +49,6 @@ export default function TicketPage() {
[router], [router],
); );
const handleShowAnalytics = useCallback(
(ticket: TicketListItem) => {
router.push(`/results/${ticket.video_id}`);
},
[router],
);
const toolbar = useMemo( const toolbar = useMemo(
() => ( () => (
<TicketFilters <TicketFilters
@@ -87,7 +80,6 @@ export default function TicketPage() {
onPageChange={setSkip} onPageChange={setSkip}
onLimitChange={setLimit} onLimitChange={setLimit}
onView={handleView} onView={handleView}
onShowAnalytics={handleShowAnalytics}
/> />
</main> </main>
); );

View File

@@ -0,0 +1,16 @@
'use client';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionRouteGuard } from '@/guards';
export default function UploadLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PermissionRouteGuard permission={PERMISSIONS.VIDEO.UPLOAD}>
{children}
</PermissionRouteGuard>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionRouteGuard } from '@/guards';
export default function UsersLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PermissionRouteGuard permission={PERMISSIONS.USER.READ}>
{children}
</PermissionRouteGuard>
);
}

View File

@@ -286,12 +286,12 @@ function DataTableContent<TData, TValue>({
<TableRow <TableRow
key={row.id} key={row.id}
data-state={row.getIsSelected() && 'selected'} data-state={row.getIsSelected() && 'selected'}
onClick={() => onRowClick?.(row.original)}
className={cn(onRowClick && 'cursor-pointer')}
> >
{row.getVisibleCells().map((cell) => { {row.getVisibleCells().map((cell) => {
const truncate = shouldTruncateCell(cell.column); const truncate = shouldTruncateCell(cell.column);
const pinned = cell.column.getIsPinned(); const pinned = cell.column.getIsPinned();
const isActionsCell = cell.column.id === 'actions';
const isRowClickable = Boolean(onRowClick && !isActionsCell);
const content = flexRender( const content = flexRender(
cell.column.columnDef.cell, cell.column.columnDef.cell,
cell.getContext(), cell.getContext(),
@@ -301,9 +301,16 @@ function DataTableContent<TData, TValue>({
<TableCell <TableCell
key={cell.id} key={cell.id}
style={getPinnedColumnStyles(cell.column)} style={getPinnedColumnStyles(cell.column)}
onClick={
isRowClickable
? () => onRowClick?.(row.original)
: undefined
}
className={cn( className={cn(
'max-w-0 overflow-hidden border-b border-border', 'max-w-0 overflow-hidden border-b border-border',
pinned && 'bg-card', pinned && 'bg-card',
isRowClickable && 'cursor-pointer',
isActionsCell && 'cursor-default',
)} )}
> >
{truncate ? ( {truncate ? (

View File

@@ -1,30 +0,0 @@
import { PERMISSIONS } from '@/constants/permissions';
import { ROUTES } from '@/utils/routes';
export type AppRoute = {
path: string;
permission?: string;
};
export const appRoutes: AppRoute[] = [
{
path: ROUTES.ROLES,
permission: PERMISSIONS.ROLE.READ,
},
{
path: ROUTES.USERS,
permission: PERMISSIONS.USER.READ,
},
{
path: ROUTES.CLIENTS,
permission: PERMISSIONS.CLIENT.READ,
},
{
path: ROUTES.PLANS,
permission: PERMISSIONS.PLAN.READ,
},
];
export function getRoutePermission(pathname: string) {
return appRoutes.find((route) => route.path === pathname)?.permission;
}

View File

@@ -35,26 +35,31 @@ export const menuItems: MenuItem[] = [
title: 'Upload', title: 'Upload',
path: ROUTES.UPLOAD, path: ROUTES.UPLOAD,
icon: Plus, icon: Plus,
permission: PERMISSIONS.VIDEO.UPLOAD,
}, },
{ {
title: 'Ticket', title: 'Ticket',
path: ROUTES.TICKET, path: ROUTES.TICKET,
icon: Ticket, icon: Ticket,
permission: PERMISSIONS.TICKET.READ,
}, },
{ {
title: 'Project', title: 'Project',
path: ROUTES.PROJECT, path: ROUTES.PROJECT,
icon: Layers, icon: Layers,
permission: PERMISSIONS.PROJECT.READ,
}, },
{ {
title: 'Package', title: 'Package',
path: ROUTES.PACKAGE, path: ROUTES.PACKAGE,
icon: Package, icon: Package,
permission: PERMISSIONS.PACKAGE.READ,
}, },
{ {
title: 'Segment', title: 'Segment',
path: ROUTES.CHAINAGE, path: ROUTES.CHAINAGE,
icon: Milestone, icon: Milestone,
permission: PERMISSIONS.SEGMENT.READ,
}, },
{ {
title: 'Administration', title: 'Administration',

View File

@@ -29,7 +29,32 @@ export const PERMISSIONS = {
UPDATE: 'administration.tenants.update', UPDATE: 'administration.tenants.update',
DELETE: 'administration.tenants.delete', DELETE: 'administration.tenants.delete',
}, },
LOG: { PROJECT: {
VIEW: 'log.view', CREATE: 'management.project.create',
READ: 'management.project.read',
UPDATE: 'management.project.update',
DELETE: 'management.project.delete',
},
PACKAGE: {
CREATE: 'management.package.create',
READ: 'management.package.read',
UPDATE: 'management.package.update',
DELETE: 'management.package.delete',
},
SEGMENT: {
CREATE: 'management.segment.create',
READ: 'management.segment.read',
UPDATE: 'management.segment.update',
DELETE: 'management.segment.delete',
},
TICKET: {
ASSIGN: 'ticket.assign',
WORK: 'ticket.work',
REVIEW: 'ticket.review',
READ: 'ticket.read',
CLOSE: 'ticket.close',
},
VIDEO: {
UPLOAD: 'video.upload',
}, },
} as const; } as const;

View File

@@ -3,22 +3,16 @@
import { AppLoader } from '@/components/AppLoader'; import { AppLoader } from '@/components/AppLoader';
import { useAppStore } from '@/store/app.store'; import { useAppStore } from '@/store/app.store';
import { useAuthStore } from '@/store/auth.store'; import { useAuthStore } from '@/store/auth.store';
import { getRoutePermission } from '@/config/app.routes';
import { usePermissions } from '@/hooks/usePermissions';
import { ROUTES } from '@/utils/routes'; import { ROUTES } from '@/utils/routes';
import { usePathname, useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
export function AuthGuard({ children }: { children: ReactNode }) { export function AuthGuard({ children }: { children: ReactNode }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const accessToken = useAuthStore((state) => state.accessToken); const accessToken = useAuthStore((state) => state.accessToken);
const isInitialized = useAppStore((state) => state.isInitialized); const isInitialized = useAppStore((state) => state.isInitialized);
const user = useAppStore((state) => state.user); const user = useAppStore((state) => state.user);
const { hasPermission } = usePermissions();
const requiredPermission = getRoutePermission(pathname);
const canAccessRoute = hasPermission(requiredPermission);
useEffect(() => { useEffect(() => {
if (!isInitialized) return; if (!isInitialized) return;
@@ -28,17 +22,11 @@ export function AuthGuard({ children }: { children: ReactNode }) {
} }
}, [accessToken, isInitialized, router]); }, [accessToken, isInitialized, router]);
useEffect(() => {
if (!isInitialized || !accessToken || !user || canAccessRoute) return;
router.replace(ROUTES.ACCESS);
}, [accessToken, canAccessRoute, isInitialized, router, user]);
if (!isInitialized || (accessToken && !user)) { if (!isInitialized || (accessToken && !user)) {
return <AppLoader label="Preparing workspace" />; return <AppLoader label="Preparing workspace" />;
} }
if (!accessToken || !user || !canAccessRoute) { if (!accessToken || !user) {
return null; return null;
} }

View File

@@ -0,0 +1,31 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, type ReactNode } from 'react';
import { usePermissions, type PermissionInput } from '@/hooks/usePermissions';
import { ROUTES } from '@/utils/routes';
interface PermissionRouteGuardProps {
children: ReactNode;
permission: PermissionInput;
}
export function PermissionRouteGuard({
children,
permission,
}: PermissionRouteGuardProps) {
const router = useRouter();
const { hasPermission } = usePermissions();
const allowed = hasPermission(permission);
useEffect(() => {
if (!allowed) {
router.replace(ROUTES.ACCESS);
}
}, [allowed, router]);
if (!allowed) return null;
return children;
}

View File

@@ -1,3 +1,4 @@
export * from './AuthGuard'; export * from './AuthGuard';
export * from './GuestGuard'; export * from './GuestGuard';
export * from './PermissionGuard'; export * from './PermissionGuard';
export * from './PermissionRouteGuard';

View File

@@ -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<PropertyKey> = [];
interface SseToken {
sse_token: string;
}
interface UseSseWithTokenOptions<TEvents extends Record<string, unknown>> {
enabled?: boolean;
getToken: () => Promise<SseToken>;
getPath: (token: string) => string;
events: SseEventMap<TEvents>;
retryDelayMs?: number;
onConnectionError?: () => void;
reconnectOnEvents?: ReadonlyArray<keyof TEvents>;
shouldStop?: (
eventName: keyof TEvents,
event: TEvents[keyof TEvents],
) => boolean;
}
export function useSseWithToken<TEvents extends Record<string, unknown>>({
enabled = true,
getToken,
getPath,
events,
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
onConnectionError,
reconnectOnEvents = EMPTY_RECONNECT_EVENTS as ReadonlyArray<keyof TEvents>,
shouldStop,
}: UseSseWithTokenOptions<TEvents>) {
const connectionRef = useRef<SseConnection | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | 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<TEvents>;
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 };
}

View File

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

View File

@@ -1,29 +1,9 @@
import type { PaginationParams } from './common'; import type { TicketStatus } from './status';
export type TicketStatus = export interface TicketActor {
| 'processing' user_id: number | null;
| 'unassigned' name: string | null;
| 'assigned' email: string | null;
| '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 TicketHistoryItem { export interface TicketHistoryItem {
@@ -35,12 +15,6 @@ export interface TicketHistoryItem {
created_at: string; created_at: string;
} }
export interface TicketActor {
user_id: number | null;
name: string | null;
email: string | null;
}
export interface TicketStatusMetadata { export interface TicketStatusMetadata {
reason: string | null; reason: string | null;
label: string | null; label: string | null;
@@ -105,66 +79,3 @@ export interface TicketDetail {
timestamps: TicketTimestamps; timestamps: TicketTimestamps;
history: TicketHistoryItem[]; 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<TicketDetail> & {
id?: string;
ticket_id?: string;
status?: TicketStatus;
};
export interface TicketProgressEvent {
ticket_id?: string;
video_id?: string;
progress?: number;
percent?: number;
message?: string;
status?: string;
}

View File

@@ -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<TicketDetail> & {
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;
}

View File

@@ -0,0 +1,5 @@
export * from './status';
export * from './list';
export * from './detail';
export * from './actions';
export * from './events';

24
src/types/ticket/list.ts Normal file
View File

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

View File

@@ -0,0 +1,6 @@
export type TicketStatus =
| 'processing'
| 'unassigned'
| 'assigned'
| 'under_review'
| 'closed';

7
src/utils/date.ts Normal file
View File

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

2
src/utils/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './routes';
export * from './date';