feat: ticket system base implementation
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user