Compare commits
2 Commits
a567a52e26
...
664d80809a
| Author | SHA1 | Date | |
|---|---|---|---|
| 664d80809a | |||
| fffd8e3e12 |
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -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.
|
||||||
|
|||||||
16
src/app/(modules)/clients/layout.tsx
Normal file
16
src/app/(modules)/clients/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/(modules)/package/layout.tsx
Normal file
16
src/app/(modules)/package/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/(modules)/plans/layout.tsx
Normal file
16
src/app/(modules)/plans/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/(modules)/project/layout.tsx
Normal file
16
src/app/(modules)/project/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/(modules)/roles/layout.tsx
Normal file
16
src/app/(modules)/roles/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/(modules)/segment/layout.tsx
Normal file
16
src/app/(modules)/segment/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/(modules)/tenants/layout.tsx
Normal file
16
src/app/(modules)/tenants/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
167
src/app/(modules)/ticket/hooks/useTenantTicketTableEvents.ts
Normal file
167
src/app/(modules)/ticket/hooks/useTenantTicketTableEvents.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
119
src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts
Normal file
119
src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
src/app/(modules)/ticket/layout.tsx
Normal file
16
src/app/(modules)/ticket/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
16
src/app/(modules)/upload/layout.tsx
Normal file
16
src/app/(modules)/upload/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/(modules)/users/layout.tsx
Normal file
16
src/app/(modules)/users/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
src/guards/PermissionRouteGuard.tsx
Normal file
31
src/guards/PermissionRouteGuard.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
142
src/hooks/sse/useSseWithToken.ts
Normal file
142
src/hooks/sse/useSseWithToken.ts
Normal 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 };
|
||||||
|
}
|
||||||
39
src/types/ticket/actions.ts
Normal file
39
src/types/ticket/actions.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
42
src/types/ticket/events.ts
Normal file
42
src/types/ticket/events.ts
Normal 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;
|
||||||
|
}
|
||||||
5
src/types/ticket/index.ts
Normal file
5
src/types/ticket/index.ts
Normal 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
24
src/types/ticket/list.ts
Normal 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;
|
||||||
|
}
|
||||||
6
src/types/ticket/status.ts
Normal file
6
src/types/ticket/status.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type TicketStatus =
|
||||||
|
| 'processing'
|
||||||
|
| 'unassigned'
|
||||||
|
| 'assigned'
|
||||||
|
| 'under_review'
|
||||||
|
| 'closed';
|
||||||
7
src/utils/date.ts
Normal file
7
src/utils/date.ts
Normal 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
2
src/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './routes';
|
||||||
|
export * from './date';
|
||||||
Reference in New Issue
Block a user