feat(ticket): refine repair report and history timeline UI

This commit is contained in:
2026-06-19 11:22:41 +05:30
parent 8c3d19a429
commit a567a52e26
27 changed files with 1550 additions and 363 deletions

View File

@@ -1,101 +1,27 @@
'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 { ArrowLeft, Loader2, Ticket } 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 { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketOverviewCard } from './components/TicketOverviewCard';
import { TicketRepairReportCard } from './components/TicketRepairReportCard';
import { TicketStatusActions } from './components/TicketStatusActions';
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>
);
}
import { useTicketDetailQuery } from '../hooks/useTicketQueries';
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">
@@ -120,10 +46,6 @@ export default function TicketDetailPage() {
);
}
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
@@ -144,258 +66,13 @@ export default function TicketDetailPage() {
<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>
<TicketOverviewCard ticket={ticket} />
<TicketRepairReportCard ticket={ticket} />
<TicketHistoryCard ticket={ticket} />
</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>
<TicketStatusActions ticket={ticket} />
</div>
</div>
</main>