feat: ticket system base implementation
This commit is contained in:
@@ -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} />}
|
||||
|
||||
403
src/app/(modules)/ticket/[ticketId]/page.tsx
Normal file
403
src/app/(modules)/ticket/[ticketId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/app/(modules)/ticket/components/TicketColumns.tsx
Normal file
60
src/app/(modules)/ticket/components/TicketColumns.tsx
Normal 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),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
}
|
||||
60
src/app/(modules)/ticket/components/TicketFilters.tsx
Normal file
60
src/app/(modules)/ticket/components/TicketFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/app/(modules)/ticket/components/TicketStatusBadge.tsx
Normal file
28
src/app/(modules)/ticket/components/TicketStatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/app/(modules)/ticket/components/TicketTable.tsx
Normal file
69
src/app/(modules)/ticket/components/TicketTable.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
157
src/app/(modules)/ticket/hooks/useTicketEvents.ts
Normal file
157
src/app/(modules)/ticket/hooks/useTicketEvents.ts
Normal 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]);
|
||||
}
|
||||
141
src/app/(modules)/ticket/hooks/useTicketQueries.ts
Normal file
141
src/app/(modules)/ticket/hooks/useTicketQueries.ts
Normal 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'),
|
||||
});
|
||||
}
|
||||
@@ -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() {
|
||||
return (
|
||||
<div className="min-h-screen pb-12">
|
||||
<main>
|
||||
<div className="mb-10">
|
||||
<PageHeader
|
||||
title="Ticket"
|
||||
description="Uploaded video details will be shown here."
|
||||
icon={Ticket}
|
||||
/>
|
||||
</div>
|
||||
const router = useRouter();
|
||||
const [skip, setSkip] = useState(0);
|
||||
const [limit, setLimit] = useState(10);
|
||||
const [status, setStatus] = useState<TicketStatus | 'all'>('all');
|
||||
const [segmentId, setSegmentId] = useState('');
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ticket Page</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This is ticket page for now.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
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 (
|
||||
<main className="relative z-10 space-y-5">
|
||||
<PageHeader
|
||||
title="Tickets"
|
||||
description="Track detection, assignment, repair, review, and closure."
|
||||
icon={Ticket}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
10
src/app/(modules)/ticket/queries/ticketKeys.ts
Normal file
10
src/app/(modules)/ticket/queries/ticketKeys.ts
Normal 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,
|
||||
};
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,3 +11,4 @@ export * from './user.service';
|
||||
export * from './client.service';
|
||||
export * from './plan.service';
|
||||
export * from './tenant.service';
|
||||
export * from './ticket.service';
|
||||
|
||||
115
src/services/api/ticket.service.ts
Normal file
115
src/services/api/ticket.service.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
1
src/services/sse/index.ts
Normal file
1
src/services/sse/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './sse.service';
|
||||
66
src/services/sse/sse.service.ts
Normal file
66
src/services/sse/sse.service.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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
157
src/types/ticket.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user