feat: ticket system base implementation

This commit is contained in:
2026-06-19 01:19:01 +05:30
parent a18fff4d6f
commit 8c3d19a429
19 changed files with 1400 additions and 27 deletions

View File

@@ -1,10 +1,11 @@
'use client';
import { useMemo } from 'react';
import { useParams } from 'next/navigation';
import { Loader2, TrendingUp } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, Loader2, TrendingUp } from 'lucide-react';
import VideoPlayerSection from '@/components/videoPlayerSection';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { CompletedVideoResult, DetectionType } from '@/types';
import { getDetectionModeConfig } from '@/constants/detectionModeConfig';
import { useVideoResultsQuery } from '../hooks/useVideoResults';
@@ -21,6 +22,7 @@ const inferDetectionType = (data: CompletedVideoResult): DetectionType => {
};
export default function VideoResultsPage() {
const router = useRouter();
const { videoId } = useParams() as { videoId: string };
const {
@@ -31,7 +33,8 @@ export default function VideoResultsPage() {
} = useVideoResultsQuery(videoId);
const detectionType = useMemo<DetectionType>(
() => (detectionData ? inferDetectionType(detectionData) : 'pothole-detection'),
() =>
detectionData ? inferDetectionType(detectionData) : 'pothole-detection',
[detectionData],
);
@@ -68,6 +71,12 @@ export default function VideoResultsPage() {
title={getTitle()}
description={`Video ID: ${videoId}`}
icon={TrendingUp}
actions={
<Button variant="outline" size="sm" onClick={() => router.back()}>
<ArrowLeft />
Back
</Button>
}
/>
{detectionData && <VideoPlayerSection data={detectionData} />}

View File

@@ -0,0 +1,403 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, Check, Loader2, Send, Ticket, X } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import type { AssignableTicketUser } from '@/types';
import {
TicketStatusBadge,
formatTicketStatus,
} from '../components/TicketStatusBadge';
import { useTicketDetailEvents } from '../hooks/useTicketEvents';
import {
useAssignableTicketUsersQuery,
useAssignTicketMutation,
useReviewRepairMutation,
useSubmitRepairMutation,
useTicketDetailQuery,
} from '../hooks/useTicketQueries';
function formatDate(value: string | null | undefined) {
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function Field({
label,
value,
}: {
label: string;
value: string | number | null | undefined;
}) {
return (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="break-words text-sm font-medium">{value ?? '-'}</p>
</div>
);
}
export default function TicketDetailPage() {
const router = useRouter();
const { ticketId } = useParams() as { ticketId: string };
const [selectedUserId, setSelectedUserId] = useState('');
const [assignNote, setAssignNote] = useState('');
const [repairNotes, setRepairNotes] = useState('');
const [proofPath, setProofPath] = useState('');
const [reviewComment, setReviewComment] = useState('');
const ticketQuery = useTicketDetailQuery(ticketId);
const ticket = ticketQuery.data;
const assignableUsersQuery = useAssignableTicketUsersQuery(
ticket?.status === 'unassigned',
);
const assignMutation = useAssignTicketMutation(ticketId);
const submitRepairMutation = useSubmitRepairMutation(ticketId);
const reviewRepairMutation = useReviewRepairMutation(ticketId);
useTicketDetailEvents(ticketId);
useEffect(() => {
if (!ticket) return;
setRepairNotes(ticket.repair?.notes ?? '');
setProofPath(ticket.repair?.proof_path ?? '');
setReviewComment(ticket.reviewer?.review_comment ?? '');
}, [ticket]);
const assignableUsers = assignableUsersQuery.data?.items ?? [];
const selectedUser = useMemo(
() =>
assignableUsers.find((user) => String(user.id) === selectedUserId) as
| AssignableTicketUser
| undefined,
[assignableUsers, selectedUserId],
);
const isBusy =
assignMutation.isPending ||
submitRepairMutation.isPending ||
reviewRepairMutation.isPending;
if (ticketQuery.isLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="size-8 animate-spin text-primary" />
</div>
);
}
if (ticketQuery.isError || !ticket) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3">
<p className="text-sm text-destructive">Failed to load ticket.</p>
<Button
variant="outline"
size="sm"
onClick={() => router.push('/ticket')}
>
<ArrowLeft />
Back to Tickets
</Button>
</div>
);
}
const canAssign = ticket.status === 'unassigned';
const canSubmitRepair = ticket.status === 'assigned';
const canReview = ticket.status === 'under_review';
return (
<main className="relative z-10 space-y-5">
<PageHeader
title="Ticket Detail"
description={`Ticket ID: ${ticket.id}`}
icon={Ticket}
actions={
<Button
variant="outline"
size="sm"
onClick={() => router.push('/ticket')}
>
<ArrowLeft />
Back
</Button>
}
/>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_380px]">
<div className="space-y-5">
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<CardTitle className="text-base">Overview</CardTitle>
<TicketStatusBadge status={ticket.status} />
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Field
label="Video"
value={ticket.video?.name ?? ticket.video_id}
/>
<Field
label="Video ID"
value={ticket.video?.id ?? ticket.video_id}
/>
<Field label="Segment ID" value={ticket.chainage_id} />
<Field
label="Detections"
value={ticket.ai_result?.detection_count}
/>
<Field
label="Uploaded By"
value={ticket.uploader?.name ?? ticket.uploader?.email}
/>
<Field
label="Created At"
value={formatDate(ticket.timestamps.created_at)}
/>
<Field
label="Updated At"
value={formatDate(ticket.timestamps.updated_at)}
/>
<Field
label="AI Completed At"
value={formatDate(ticket.ai_result?.completed_at)}
/>
<Field
label="Assigned To"
value={ticket.worker?.name ?? ticket.worker?.email}
/>
<Field
label="Assigned At"
value={formatDate(ticket.worker?.assigned_at)}
/>
<Field
label="Assigned By"
value={ticket.worker?.assigned_by_name}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Repair Review</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<Field label="Repair Notes" value={ticket.repair?.notes} />
<Field label="Proof Path" value={ticket.repair?.proof_path} />
<Field
label="Repair Submitted At"
value={formatDate(ticket.repair?.submitted_at)}
/>
<Field
label="Reviewer"
value={ticket.reviewer?.name ?? ticket.reviewer?.email}
/>
<Field
label="Review Comment"
value={ticket.reviewer?.review_comment}
/>
<Field
label="Reviewed At"
value={formatDate(ticket.reviewer?.reviewed_at)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">History</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{ticket.history.length ? (
ticket.history.map((item) => (
<div
key={item.id}
className="border-l-2 border-border pl-4"
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">
{item.from_status
? `${formatTicketStatus(item.from_status)} -> `
: ''}
{formatTicketStatus(item.to_status)}
</span>
<span className="text-xs text-muted-foreground">
{formatDate(item.created_at)}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{item.note || '-'}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{item.actor?.name || item.actor?.email || 'System'}
</p>
</div>
))
) : (
<p className="text-sm text-muted-foreground">
No history yet.
</p>
)}
</div>
</CardContent>
</Card>
</div>
<div className="space-y-5">
{canAssign && (
<Card>
<CardHeader>
<CardTitle className="text-base">Assign Ticket</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Worker</Label>
<Select
value={selectedUserId}
onValueChange={setSelectedUserId}
>
<SelectTrigger>
<SelectValue placeholder="Select worker" />
</SelectTrigger>
<SelectContent>
{assignableUsers.map((user) => (
<SelectItem key={user.id} value={String(user.id)}>
{user.name || user.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Note</Label>
<Textarea
value={assignNote}
onChange={(event) => setAssignNote(event.target.value)}
placeholder="Please inspect and repair"
/>
</div>
<Button
disabled={!selectedUser || isBusy}
onClick={() => {
if (!selectedUser) return;
assignMutation.mutate({
assigned_to_user_id: selectedUser.id,
assigned_to_email: selectedUser.email,
note: assignNote || undefined,
});
}}
className="w-full"
>
<Send />
Assign
</Button>
</CardContent>
</Card>
)}
{canSubmitRepair && (
<Card>
<CardHeader>
<CardTitle className="text-base">Submit Repair</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Notes</Label>
<Textarea
value={repairNotes}
onChange={(event) => setRepairNotes(event.target.value)}
placeholder="Repair notes"
/>
</div>
<div className="space-y-2">
<Label>Proof Path</Label>
<Input
value={proofPath}
onChange={(event) => setProofPath(event.target.value)}
placeholder="/some/uploaded/proof.jpg"
/>
</div>
<Button
disabled={!repairNotes || !proofPath || isBusy}
onClick={() =>
submitRepairMutation.mutate({
notes: repairNotes,
proof_path: proofPath,
})
}
className="w-full"
>
<Send />
Submit Repair
</Button>
</CardContent>
</Card>
)}
{canReview && (
<Card>
<CardHeader>
<CardTitle className="text-base">Review Repair</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Comment</Label>
<Textarea
value={reviewComment}
onChange={(event) => setReviewComment(event.target.value)}
placeholder="Repair verified"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
disabled={!reviewComment || isBusy}
onClick={() =>
reviewRepairMutation.mutate({
action: 'approve',
comment: reviewComment,
})
}
>
<Check />
Approve
</Button>
<Button
variant="destructive"
disabled={!reviewComment || isBusy}
onClick={() =>
reviewRepairMutation.mutate({
action: 'reject',
comment: reviewComment,
})
}
>
<X />
Reject
</Button>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,60 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import type { TicketListItem } from '@/types';
import { TicketStatusBadge } from './TicketStatusBadge';
function formatDate(value: string | null | undefined) {
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
export function useTicketColumns(): ColumnDef<TicketListItem>[] {
return useMemo(
() => [
{
accessorKey: 'id',
header: 'Ticket',
size: 180,
cell: ({ row }) => (
<span className="font-medium">{row.original.id}</span>
),
},
{
accessorKey: 'status',
header: 'Status',
size: 150,
cell: ({ row }) => <TicketStatusBadge status={row.original.status} />,
},
{
accessorKey: 'detection_count',
header: 'Detections',
size: 120,
},
{
accessorKey: 'assigned_to_email',
header: 'Assigned To',
size: 220,
cell: ({ row }) => row.original.assigned_to_email || '-',
},
{
accessorKey: 'created_by_email',
header: 'Uploaded By',
size: 220,
},
{
accessorKey: 'updated_at',
header: 'Updated',
size: 180,
cell: ({ row }) => formatDate(row.original.updated_at),
},
],
[],
);
}

View File

@@ -0,0 +1,60 @@
'use client';
import { SegmentSelect } from '@/components/lookups/SegmentSelect';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { TicketStatus } from '@/types';
import { formatTicketStatus } from './TicketStatusBadge';
const ticketStatuses: TicketStatus[] = [
'processing',
'unassigned',
'assigned',
'under_review',
'closed',
];
interface TicketFiltersProps {
status: TicketStatus | 'all';
segmentId: string;
onStatusChange: (status: TicketStatus | 'all') => void;
onSegmentChange: (segmentId: string) => void;
}
export function TicketFilters({
status,
segmentId,
onStatusChange,
onSegmentChange,
}: TicketFiltersProps) {
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Select
value={status}
onValueChange={(value) => onStatusChange(value as TicketStatus | 'all')}
>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{ticketStatuses.map((item) => (
<SelectItem key={item} value={item}>
{formatTicketStatus(item)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="w-full sm:w-64">
<SegmentSelect value={segmentId} onValueChange={onSegmentChange} />
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { TicketStatus } from '@/types';
const statusClasses: Record<TicketStatus, string> = {
processing: 'bg-sky-100 text-sky-700 border-sky-200',
unassigned: 'bg-amber-100 text-amber-800 border-amber-200',
assigned: 'bg-indigo-100 text-indigo-700 border-indigo-200',
under_review: 'bg-violet-100 text-violet-700 border-violet-200',
closed: 'bg-stone-100 text-stone-700 border-stone-200',
};
export function formatTicketStatus(status: TicketStatus) {
return status
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
export function TicketStatusBadge({ status }: { status: TicketStatus }) {
return (
<Badge variant="outline" className={cn('border', statusClasses[status])}>
{formatTicketStatus(status)}
</Badge>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { BarChart3, Eye, ChartPie } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import type { TicketListItem } from '@/types';
interface TicketTableProps {
columns: ColumnDef<TicketListItem>[];
tickets: TicketListItem[];
isLoading: boolean;
toolbar: ReactNode;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onView: (ticket: TicketListItem) => void;
onShowAnalytics: (ticket: TicketListItem) => void;
}
export function TicketTable({
columns,
tickets,
isLoading,
toolbar,
skip,
limit,
total,
onPageChange,
onLimitChange,
onView,
onShowAnalytics,
}: TicketTableProps) {
return (
<DataTable
title="Tickets"
columns={columns}
data={tickets}
isLoading={isLoading}
toolbar={toolbar}
emptyTitle="No tickets found."
emptyDescription="Ticket rows will appear after video processing creates them."
actions={[
{
label: 'View',
icon: <Eye className="size-4" />,
onClick: onView,
},
{
label: 'Show Analytics',
icon: <ChartPie className="size-4" />,
onClick: onShowAnalytics,
},
]}
pagination={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange,
}}
onRowClick={onView}
actionsSticky
/>
);
}

View File

@@ -0,0 +1,157 @@
'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,
...event,
id: ticket.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

@@ -0,0 +1,141 @@
'use client';
import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ticketService } from '@/services/api';
import type {
AssignTicketPayload,
ReviewRepairPayload,
SubmitRepairPayload,
TicketListParams,
TicketStatus,
} from '@/types';
import { ticketKeys } from '../queries/ticketKeys';
interface UseTicketsQueryParams {
skip: number;
limit: number;
status?: TicketStatus | 'all';
chainageId?: string;
}
function buildTicketListParams({
skip,
limit,
status,
chainageId,
}: UseTicketsQueryParams): TicketListParams {
return {
skip,
limit,
status: status === 'all' ? undefined : status,
chainage_id: chainageId || undefined,
};
}
export function useTicketsQuery(params: UseTicketsQueryParams) {
const { skip, limit, status, chainageId } = params;
const listParams = useMemo(
() => buildTicketListParams({ skip, limit, status, chainageId }),
[chainageId, limit, skip, status],
);
return useQuery({
queryKey: ticketKeys.list(listParams),
queryFn: () => ticketService.getTickets(listParams),
});
}
export function useTicketDetailQuery(ticketId: string | undefined) {
return useQuery({
queryKey: ticketKeys.detail(ticketId ?? ''),
queryFn: () => ticketService.getTicketDetail(ticketId as string),
enabled: Boolean(ticketId),
});
}
export function useAssignableTicketUsersQuery(enabled: boolean) {
return useQuery({
queryKey: ticketKeys.assignableUsers(),
queryFn: ticketService.getAssignableUsers,
enabled,
});
}
export function useAssignTicketMutation(ticketId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: AssignTicketPayload) =>
ticketService.assignTicket(ticketId, payload),
onSuccess: (ticket) => {
toast.success('Ticket assigned');
queryClient.setQueryData(ticketKeys.detail(ticketId), ticket);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to assign ticket'),
});
}
export function useStartTicketMutation(ticketId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => ticketService.startTicket(ticketId),
onSuccess: (ticket) => {
toast.success('Ticket started');
queryClient.setQueryData(ticketKeys.detail(ticketId), ticket);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to start ticket'),
});
}
export function useSubmitRepairMutation(ticketId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: SubmitRepairPayload) =>
ticketService.submitRepair(ticketId, payload),
onSuccess: (ticket) => {
toast.success('Repair submitted');
queryClient.setQueryData(ticketKeys.detail(ticketId), ticket);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to submit repair'),
});
}
export function useReviewRepairMutation(ticketId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: ReviewRepairPayload) =>
ticketService.reviewRepair(ticketId, payload),
onSuccess: (ticket, payload) => {
toast.success(
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
);
queryClient.setQueryData(ticketKeys.detail(ticketId), ticket);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to review repair'),
});
}
export function useCloseTicketMutation(ticketId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => ticketService.closeTicket(ticketId),
onSuccess: (ticket) => {
toast.success('Ticket closed');
queryClient.setQueryData(ticketKeys.detail(ticketId), ticket);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to close ticket'),
});
}

View File

@@ -1,32 +1,94 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { PageHeader } from '@/components/page-header';
import { Ticket } from 'lucide-react';
import type { TicketListItem, TicketStatus } from '@/types';
import { TicketFilters } from './components/TicketFilters';
import { TicketTable } from './components/TicketTable';
import { useTicketColumns } from './components/TicketColumns';
import { useTenantTicketTableEvents } from './hooks/useTicketEvents';
import { useTicketsQuery } from './hooks/useTicketQueries';
export default function TicketPage() {
const router = useRouter();
const [skip, setSkip] = useState(0);
const [limit, setLimit] = useState(10);
const [status, setStatus] = useState<TicketStatus | 'all'>('all');
const [segmentId, setSegmentId] = useState('');
useTenantTicketTableEvents();
const ticketsQuery = useTicketsQuery({
skip,
limit,
status,
chainageId: segmentId,
});
const columns = useTicketColumns();
const tickets = ticketsQuery.data?.items ?? [];
const total = ticketsQuery.data?.total ?? 0;
const handleStatusChange = useCallback((nextStatus: TicketStatus | 'all') => {
setStatus(nextStatus);
setSkip(0);
}, []);
const handleSegmentChange = useCallback((nextSegmentId: string) => {
setSegmentId(nextSegmentId);
setSkip(0);
}, []);
const handleView = useCallback(
(ticket: TicketListItem) => {
router.push(`/ticket/${ticket.id}`);
},
[router],
);
const handleShowAnalytics = useCallback(
(ticket: TicketListItem) => {
router.push(`/results/${ticket.video_id}`);
},
[router],
);
const toolbar = useMemo(
() => (
<TicketFilters
status={status}
segmentId={segmentId}
onStatusChange={handleStatusChange}
onSegmentChange={handleSegmentChange}
/>
),
[handleSegmentChange, handleStatusChange, segmentId, status],
);
return (
<div className="min-h-screen pb-12">
<main>
<div className="mb-10">
<main className="relative z-10 space-y-5">
<PageHeader
title="Ticket"
description="Uploaded video details will be shown here."
title="Tickets"
description="Track detection, assignment, repair, review, and closure."
icon={Ticket}
/>
</div>
<Card>
<CardHeader>
<CardTitle>Ticket Page</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
This is ticket page for now.
</p>
</CardContent>
</Card>
<TicketTable
columns={columns}
tickets={tickets}
isLoading={ticketsQuery.isLoading}
toolbar={toolbar}
skip={skip}
limit={limit}
total={total}
onPageChange={setSkip}
onLimitChange={setLimit}
onView={handleView}
onShowAnalytics={handleShowAnalytics}
/>
</main>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import type { TicketListParams } from '@/types';
export const ticketKeys = {
all: ['tickets'] as const,
lists: () => [...ticketKeys.all, 'list'] as const,
list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const,
details: () => [...ticketKeys.all, 'detail'] as const,
detail: (ticketId: string) => [...ticketKeys.details(), ticketId] as const,
assignableUsers: () => [...ticketKeys.all, 'assignable-users'] as const,
};

View File

@@ -272,7 +272,10 @@ function DataTableContent<TData, TValue>({
Array.from({ length: 5 }).map((_, idx) => (
<TableRow key={idx}>
{columns.map((_, colIdx) => (
<TableCell key={colIdx}>
<TableCell
key={colIdx}
className="border-b border-border"
>
<Skeleton className="h-4 w-full max-w-35" />
</TableCell>
))}

View File

@@ -6,6 +6,7 @@ import {
Plus,
Settings,
ShieldCheck,
Ticket,
Users,
Building2,
BadgeIndianRupee,
@@ -35,6 +36,11 @@ export const menuItems: MenuItem[] = [
path: ROUTES.UPLOAD,
icon: Plus,
},
{
title: 'Ticket',
path: ROUTES.TICKET,
icon: Ticket,
},
{
title: 'Project',
path: ROUTES.PROJECT,

View File

@@ -55,4 +55,28 @@ export const API_ROUTES = {
MY_UPLOADS: '/biz/api/v1/videos/me',
RESULTS: (id: string) => `/biz/api/v1/results/${id}/completed`,
},
TICKETS: {
BASE: '/biz/api/v1/tickets',
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,
START: (id: string) => `/biz/api/v1/tickets/${id}/start`,
SUBMIT_REPAIR: (id: string) => `/biz/api/v1/tickets/${id}/submit-repair`,
REVIEW: (id: string) => `/biz/api/v1/tickets/${id}/review`,
CLOSE: (id: string) => `/biz/api/v1/tickets/${id}/close`,
DETAIL_EVENTS_TOKEN: (id: string) =>
`/biz/api/v1/tickets/${id}/events/token`,
DETAIL_EVENTS: (id: string, token: string) =>
`/biz/api/v1/tickets/${id}/events?sse_token=${encodeURIComponent(token)}`,
ASSIGNABLE_USERS: '/biz/api/v1/tickets/assignable-users',
},
TICKET_EVENTS: {
TENANT_TOKEN: '/biz/api/v1/tenants/me/tickets/events/token',
TENANT: (token: string) =>
`/biz/api/v1/tenants/me/tickets/events?sse_token=${encodeURIComponent(
token,
)}`,
USER_TOKEN: '/biz/api/v1/users/me/events/token',
USER: (token: string) =>
`/biz/api/v1/users/me/events?sse_token=${encodeURIComponent(token)}`,
},
} as const;

View File

@@ -11,3 +11,4 @@ export * from './user.service';
export * from './client.service';
export * from './plan.service';
export * from './tenant.service';
export * from './ticket.service';

View File

@@ -0,0 +1,115 @@
import axiosClient from '../axios/axios';
import { API_ROUTES } from '@/constants/apiRoutes';
import type {
AssignTicketPayload,
AssignableTicketUsersResponse,
ReviewRepairPayload,
SseTokenResponse,
SubmitRepairPayload,
TicketDetail,
TicketListParams,
TicketListResponse,
} from '@/types';
export const ticketService = {
getTickets: async (
params?: TicketListParams,
): Promise<TicketListResponse> => {
const response = await axiosClient.get<TicketListResponse>(
API_ROUTES.TICKETS.BASE,
{
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 10,
status: params?.status,
chainage_id: params?.chainage_id,
},
},
);
return response.data;
},
getTicketDetail: async (ticketId: string): Promise<TicketDetail> => {
const response = await axiosClient.get<TicketDetail>(
API_ROUTES.TICKETS.DETAIL(ticketId),
);
return response.data;
},
getAssignableUsers: async (): Promise<AssignableTicketUsersResponse> => {
const response = await axiosClient.get<AssignableTicketUsersResponse>(
API_ROUTES.TICKETS.ASSIGNABLE_USERS,
);
return response.data;
},
assignTicket: async (
ticketId: string,
payload: AssignTicketPayload,
): Promise<TicketDetail> => {
const response = await axiosClient.post<TicketDetail>(
API_ROUTES.TICKETS.ASSIGN(ticketId),
payload,
);
return response.data;
},
startTicket: async (ticketId: string): Promise<TicketDetail> => {
const response = await axiosClient.post<TicketDetail>(
API_ROUTES.TICKETS.START(ticketId),
);
return response.data;
},
submitRepair: async (
ticketId: string,
payload: SubmitRepairPayload,
): Promise<TicketDetail> => {
const response = await axiosClient.post<TicketDetail>(
API_ROUTES.TICKETS.SUBMIT_REPAIR(ticketId),
payload,
);
return response.data;
},
reviewRepair: async (
ticketId: string,
payload: ReviewRepairPayload,
): Promise<TicketDetail> => {
const response = await axiosClient.post<TicketDetail>(
API_ROUTES.TICKETS.REVIEW(ticketId),
payload,
);
return response.data;
},
closeTicket: async (ticketId: string): Promise<TicketDetail> => {
const response = await axiosClient.post<TicketDetail>(
API_ROUTES.TICKETS.CLOSE(ticketId),
);
return response.data;
},
createTicketEventsToken: async (
ticketId: string,
): Promise<SseTokenResponse> => {
const response = await axiosClient.post<SseTokenResponse>(
API_ROUTES.TICKETS.DETAIL_EVENTS_TOKEN(ticketId),
);
return response.data;
},
createTenantTicketEventsToken: async (): Promise<SseTokenResponse> => {
const response = await axiosClient.post<SseTokenResponse>(
API_ROUTES.TICKET_EVENTS.TENANT_TOKEN,
);
return response.data;
},
createUserEventsToken: async (): Promise<SseTokenResponse> => {
const response = await axiosClient.post<SseTokenResponse>(
API_ROUTES.TICKET_EVENTS.USER_TOKEN,
);
return response.data;
},
};

View File

@@ -0,0 +1 @@
export * from './sse.service';

View File

@@ -0,0 +1,66 @@
import { ENV_CONSTANT } from '@/constants/secrect.constant';
export type SseEventHandler<T = unknown> = (
data: T,
event: MessageEvent,
) => void;
export type SseEventMap<TEvents extends Record<string, unknown>> = {
[EventName in keyof TEvents]: SseEventHandler<TEvents[EventName]>;
};
export interface SseConnection {
source: EventSource;
close: () => void;
}
function getSseUrl(path: string) {
if (/^https?:\/\//i.test(path)) return path;
const baseUrl = ENV_CONSTANT.BASE_API_URL ?? '';
const normalizedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${normalizedBase}${normalizedPath}`;
}
function parseEventData(event: MessageEvent) {
if (!event.data) return null;
try {
return JSON.parse(event.data);
} catch {
return event.data;
}
}
export const sseService = {
connect: <TEvents extends Record<string, unknown>>(
path: string,
events: SseEventMap<TEvents>,
): SseConnection => {
const source = new EventSource(getSseUrl(path));
const cleanups: Array<() => void> = [];
Object.entries(events).forEach(([eventName, handler]) => {
const listener = (event: Event) => {
const messageEvent = event as MessageEvent;
(handler as SseEventHandler)(
parseEventData(messageEvent),
messageEvent,
);
};
source.addEventListener(eventName, listener);
cleanups.push(() => source.removeEventListener(eventName, listener));
});
return {
source,
close: () => {
cleanups.forEach((cleanup) => cleanup());
source.close();
},
};
},
};

View File

@@ -13,3 +13,4 @@ export * from './user';
export * from './client';
export * from './plan';
export * from './tenant';
export * from './ticket';

157
src/types/ticket.ts Normal file
View File

@@ -0,0 +1,157 @@
import type { PaginationParams } from './common';
export type TicketStatus =
| 'processing'
| 'unassigned'
| 'assigned'
| 'under_review'
| 'closed';
export interface TicketListParams extends PaginationParams {
status?: TicketStatus;
chainage_id?: string;
}
export interface TicketListItem {
id: string;
video_id: string;
chainage_id: string;
status: TicketStatus;
assigned_to_user_id: number | null;
assigned_to_email: string | null;
detection_count: number;
created_by_user_id: number;
created_by_email: string;
created_at: string;
updated_at: string;
}
export interface TicketHistoryItem {
id: number;
from_status: TicketStatus | null;
to_status: TicketStatus;
actor: TicketActor | null;
note: string | null;
created_at: string;
}
export interface TicketActor {
user_id: number;
name: string | null;
email: string | null;
}
export interface TicketStatusMetadata {
reason: string | null;
label: string | null;
message: string | null;
}
export interface TicketVideo {
id: string;
name: string | null;
url: string | null;
thumbnail: string | null;
}
export interface TicketWorker extends TicketActor {
assigned_at: string | null;
assigned_by_user_id: number | null;
assigned_by_name: string | null;
}
export interface TicketRepair {
notes: string | null;
proof_path: string | null;
submitted_at: string | null;
}
export interface TicketReviewer extends TicketActor {
review_comment: string | null;
reviewed_at: string | null;
}
export interface TicketAiResult {
detection_count: number;
completed_at: string | null;
}
export interface TicketTenant {
root_tenant_id: number;
organization_id: number;
}
export interface TicketTimestamps {
created_at: string;
updated_at: string;
}
export interface TicketDetail {
id: string;
video_id: string;
chainage_id: string;
status: TicketStatus;
status_metadata: TicketStatusMetadata | null;
video: TicketVideo | null;
uploader: TicketActor | null;
worker: TicketWorker | null;
repair: TicketRepair | null;
reviewer: TicketReviewer | null;
ai_result: TicketAiResult | null;
tenant: TicketTenant | null;
timestamps: TicketTimestamps;
history: TicketHistoryItem[];
}
export interface TicketListResponse {
items: TicketListItem[];
total: number;
}
export interface AssignableTicketUser {
id: number;
name: string;
email: string;
username: 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 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;
}