feat(ticket): add audit timeline and safeguard extension rejection

This commit is contained in:
2026-07-31 11:51:32 +05:30
parent 58098af0fe
commit 44d3be5d27
16 changed files with 394 additions and 210 deletions

View File

@@ -1,74 +1,5 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Timeline, TimelineItem } from '@/components/ui/timeline';
function TimelineEntrySkeleton({
index,
isLast,
}: {
index: number;
isLast: boolean;
}) {
return (
<TimelineItem
layout="left"
isLast={isLast}
opposite={
<div className="space-y-2">
<Skeleton className="ml-auto h-4 w-16" />
<Skeleton className="ml-auto h-3 w-12" />
</div>
}
marker={<Skeleton className="size-4 rounded-full" />}
>
<div
className={
index === 0
? 'rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5'
: 'rounded-lg border bg-card p-4'
}
>
{index === 0 ? <Skeleton className="h-5 w-28 rounded-lg" /> : null}
<Skeleton className={index === 0 ? 'mt-3 h-4 w-44' : 'h-4 w-44'} />
{index === 0 ? (
<Skeleton className="mt-2 h-3 w-full max-w-md" />
) : (
<>
<div className="mt-3 flex items-center gap-3">
<Skeleton className="size-9 rounded-full" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-3 w-48 max-w-full" />
</div>
</div>
<Skeleton className="mt-3 h-16 w-full rounded-lg" />
</>
)}
</div>
</TimelineItem>
);
}
export function TicketClassContentSkeleton() {
return (
<div className="space-y-4" aria-label="Loading defect class details">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-lg" />
<Skeleton className="h-5 w-56 max-w-full" />
</div>
<Timeline>
{Array.from({ length: 3 }).map((_, index) => (
<TimelineEntrySkeleton
key={index}
index={index}
isLast={index === 2}
/>
))}
</Timeline>
</div>
);
}
export function TicketClassActionsSkeleton() { export function TicketClassActionsSkeleton() {
return ( return (

View File

@@ -1,28 +1,56 @@
'use client'; 'use client';
import { History, MessageSquareText } from 'lucide-react'; import { History, MessageSquareText, RefreshCw } from 'lucide-react';
import { PersonInfo } from '@/components/person-avatar'; import { PersonInfo } from '@/components/person-avatar';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Timeline, TimelineItem } from '@/components/ui/timeline'; import { Timeline, TimelineItem } from '@/components/ui/timeline';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { TicketDetail, TicketHistoryItem, TicketHistorySource } from '@/types'; import type {
TicketTimelineEventType,
TicketTimelineItem,
TicketTimelineNoteKind,
TicketTimelineSource,
TicketTimelineUser,
} from '@/types';
import { formatTimelineDateTime } from '@/utils/date'; import { formatTimelineDateTime } from '@/utils/date';
import { getPersonLabel } from '@/utils/person'; import { getPersonLabel } from '@/utils/person';
import { useTicketTimelineQuery } from '../../hooks/useTicketQueries';
import { TicketTimelineSkeleton } from './TicketTimelineSkeleton';
function TimelineDate({ value }: { value: string }) { function TimelineDate({ value }: { value: string }) {
const { date, time } = formatTimelineDateTime(value); const { date, time } = formatTimelineDateTime(value);
return ( return (
<time dateTime={value} className="block tabular-nums"> <time dateTime={value} className="block tabular-nums">
<span className="block text-sm font-semibold text-foreground">{date}</span> <span className="block text-sm font-semibold text-foreground">
{date}
</span>
<span className="block text-xs text-muted-foreground">{time}</span> <span className="block text-xs text-muted-foreground">{time}</span>
</time> </time>
); );
} }
function HistoryNote({ item }: { item: TicketHistoryItem }) { function getNoteKindLabel(kind: TicketTimelineNoteKind) {
const noteText = item.note?.text?.trim(); switch (kind) {
case 'system_note':
return 'System note';
case 'repair_note':
return 'Repair note';
case 'review_comment':
return 'Review comment';
default:
return 'Note';
}
}
function HistoryNote({ item }: { item: TicketTimelineItem }) {
const note = item.note;
if (!note) return null;
const noteText = note.text.trim();
if (!noteText) return null; if (!noteText) return null;
return ( return (
@@ -30,7 +58,7 @@ function HistoryNote({ item }: { item: TicketHistoryItem }) {
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-900 dark:text-amber-200"> <div className="flex items-center gap-1.5 text-xs font-medium text-amber-900 dark:text-amber-200">
<MessageSquareText className="size-3.5 shrink-0 text-amber-700 dark:text-amber-300" /> <MessageSquareText className="size-3.5 shrink-0 text-amber-700 dark:text-amber-300" />
<span> <span>
Note by {getPersonLabel(item.note?.author ?? item.actor)} {getNoteKindLabel(note.kind)} by {getPersonLabel(note.author)}
</span> </span>
</div> </div>
<p className="mt-1.5 text-sm leading-relaxed text-amber-950/80 dark:text-amber-100/90"> <p className="mt-1.5 text-sm leading-relaxed text-amber-950/80 dark:text-amber-100/90">
@@ -40,22 +68,21 @@ function HistoryNote({ item }: { item: TicketHistoryItem }) {
); );
} }
function getSourceLabel(source: TicketHistorySource) { function getSourceLabel(source: TicketTimelineSource) {
return source === 'system' ? 'System Generated' : 'User Action'; return source === 'system' ? 'System Generated' : 'User Action';
} }
function SystemHistoryEntry({ item }: { item: TicketHistoryItem }) { function SystemHistoryEntry({ item }: { item: TicketTimelineItem }) {
const noteText = item.note?.text?.trim(); const noteText = item.note?.text?.trim();
return ( return (
<div className="rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5"> <div className="rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5">
<Badge <Badge className="rounded-lg px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase">
className="rounded-lg px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase"
>
{getSourceLabel(item.source)} {getSourceLabel(item.source)}
</Badge> </Badge>
<h3 className="mt-2 text-sm font-semibold text-foreground">{item.title}</h3> <h3 className="mt-2 text-sm font-semibold text-foreground">
{item.title}
</h3>
{noteText ? ( {noteText ? (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground"> <p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{noteText} {noteText}
@@ -65,17 +92,52 @@ function SystemHistoryEntry({ item }: { item: TicketHistoryItem }) {
); );
} }
function UserHistoryEntry({ item }: { item: TicketHistoryItem }) { function isSameUser(actor: TicketTimelineUser, target: TicketTimelineUser) {
if (actor.user_id !== null && target.user_id !== null) {
return actor.user_id === target.user_id;
}
const actorEmail = actor.email?.trim().toLowerCase();
const targetEmail = target.email?.trim().toLowerCase();
if (actorEmail && targetEmail) return actorEmail === targetEmail;
return (
Boolean(actor.name?.trim()) &&
actor.name?.trim().toLowerCase() === target.name?.trim().toLowerCase()
);
}
function getTargetLabel(eventType: TicketTimelineEventType | null) {
switch (eventType) {
case 'assignment_added':
return 'Assigned to';
case 'assignment_replaced':
return 'Replacement worker';
case 'assignment_updated':
return 'Assigned worker';
case 'assignment_deleted':
return 'Removed worker';
default:
return 'Affected worker';
}
}
function UserHistoryEntry({ item }: { item: TicketTimelineItem }) {
const noteText = item.note?.text?.trim(); const noteText = item.note?.text?.trim();
const isAssigned = item.event_type === 'assigned'; const showTargetUser =
item.target_user && !isSameUser(item.actor, item.target_user);
return ( return (
<div className="rounded-lg border bg-card p-4"> <div className="rounded-lg border bg-card p-4">
<h3 className="mb-3 text-sm font-semibold text-foreground">{item.title}</h3> <h3 className="mb-3 text-sm font-semibold text-foreground">
{item.title}
</h3>
<PersonInfo person={item.actor} /> <PersonInfo person={item.actor} />
{isAssigned && item.target_user ? ( {showTargetUser && item.target_user ? (
<div className="mt-3 space-y-1"> <div className="mt-3 space-y-1">
<p className="text-xs text-muted-foreground">Assigned to</p> <p className="text-xs text-muted-foreground">
{getTargetLabel(item.event_type)}
</p>
<PersonInfo person={item.target_user} /> <PersonInfo person={item.target_user} />
</div> </div>
) : null} ) : null}
@@ -84,17 +146,54 @@ function UserHistoryEntry({ item }: { item: TicketHistoryItem }) {
); );
} }
export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) { function TimelineHeader() {
const history = ticket.history ?? [];
return ( return (
<div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<History className="size-5 text-primary" /> <History className="size-5 text-primary" />
<h2 className="text-base font-semibold">Audit & Lifecycle Timeline</h2> <h2 className="text-base font-semibold">Audit & Lifecycle Timeline</h2>
</div> </div>
);
}
{history.length ? ( export function TicketHistoryCard({ ticketId }: { ticketId: string }) {
const timelineQuery = useTicketTimelineQuery(ticketId);
if (timelineQuery.isLoading) {
return <TicketTimelineSkeleton />;
}
const history = timelineQuery.data?.history ?? [];
return (
<div className="space-y-4">
<TimelineHeader />
{timelineQuery.isError ? (
<div
role="alert"
className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-6 text-center"
>
<p className="text-sm text-destructive">
Failed to load the ticket timeline.
</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
disabled={timelineQuery.isFetching}
onClick={() => timelineQuery.refetch()}
>
<RefreshCw
className={cn(
'size-4',
timelineQuery.isFetching && 'animate-spin',
)}
/>
Retry
</Button>
</div>
) : history.length ? (
<Timeline> <Timeline>
{history.map((item, index) => ( {history.map((item, index) => (
<TimelineItem <TimelineItem

View File

@@ -4,8 +4,6 @@ import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
import type { TicketAssignmentSummary, TicketDetail } from '@/types'; import type { TicketAssignmentSummary, TicketDetail } from '@/types';
import { AssignedContractorAction } from './actions/AssignedContractorAction';
import { ClosedTicketAction } from './actions/ClosedTicketAction';
import { NoTicketAction } from './actions/NoTicketAction'; import { NoTicketAction } from './actions/NoTicketAction';
import { RequestExtensionAction } from './actions/RequestExtensionAction'; import { RequestExtensionAction } from './actions/RequestExtensionAction';
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction'; import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
@@ -36,48 +34,17 @@ function getTicketAction(
</PermissionGuard> </PermissionGuard>
} }
> >
<>
{ticket.assignment_status === 'assigned' ? (
<AssignedContractorAction ticket={ticket} />
) : null}
<ReviewExtensionRequestAction <ReviewExtensionRequestAction
ticket={ticket} ticket={ticket}
assignmentId={assignment.id} assignmentId={assignment.id}
/> />
</>
</PermissionGuard> </PermissionGuard>
); );
} }
if (ticket.assignment_status === 'unassigned') {
return null; return null;
} }
if (ticket.assignment_status === 'assigned') {
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={<NoTicketAction />}
>
<AssignedContractorAction ticket={ticket} />
</PermissionGuard>
);
}
if (ticket.assignment_status === 'under_review') {
return null;
}
if (
ticket.assignment_status === 'approved' ||
ticket.assignment_status === 'rejected'
) {
return <ClosedTicketAction ticket={ticket} />;
}
return <NoTicketAction />;
}
export function TicketStatusActions({ export function TicketStatusActions({
ticket, ticket,
assignment, assignment,

View File

@@ -0,0 +1,70 @@
import { Skeleton } from '@/components/ui/skeleton';
import { Timeline, TimelineItem } from '@/components/ui/timeline';
function TimelineEntrySkeleton({
index,
isLast,
}: {
index: number;
isLast: boolean;
}) {
return (
<TimelineItem
layout="left"
isLast={isLast}
opposite={
<div className="space-y-2">
<Skeleton className="ml-auto h-4 w-16" />
<Skeleton className="ml-auto h-3 w-12" />
</div>
}
marker={<Skeleton className="size-4 rounded-full" />}
>
<div
className={
index === 0
? 'rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5'
: 'rounded-lg border bg-card p-4'
}
>
{index === 0 ? <Skeleton className="h-5 w-28 rounded-lg" /> : null}
<Skeleton className={index === 0 ? 'mt-3 h-4 w-44' : 'h-4 w-44'} />
{index === 0 ? (
<Skeleton className="mt-2 h-3 w-full max-w-md" />
) : (
<>
<div className="mt-3 flex items-center gap-3">
<Skeleton className="size-9 rounded-full" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-3 w-48 max-w-full" />
</div>
</div>
<Skeleton className="mt-3 h-16 w-full rounded-lg" />
</>
)}
</div>
</TimelineItem>
);
}
export function TicketTimelineSkeleton() {
return (
<div className="space-y-4" aria-label="Loading ticket timeline">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-lg" />
<Skeleton className="h-5 w-56 max-w-full" />
</div>
<Timeline>
{Array.from({ length: 3 }).map((_, index) => (
<TimelineEntrySkeleton
key={index}
index={index}
isLast={index === 2}
/>
))}
</Timeline>
</div>
);
}

View File

@@ -16,21 +16,6 @@ import { cn } from '@/lib/utils';
import type { TicketDetail } from '@/types'; import type { TicketDetail } from '@/types';
import { formatDateOnly } from '@/utils/date'; import { formatDateOnly } from '@/utils/date';
function getClosedAt(ticket: TicketDetail) {
const approvedEntry = [...(ticket.history ?? [])]
.reverse()
.find(
(item) =>
item.event_type === 'repair_approved' ||
item.to_class_status === 'approved',
);
return (
approvedEntry?.created_at ??
ticket.reviewer?.reviewed_at ??
ticket.timestamps.updated_at
);
}
function MetaField({ function MetaField({
label, label,
value, value,
@@ -79,13 +64,15 @@ function FinalCommentsPanel({
export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) { export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
const reviewer = ticket.reviewer; const reviewer = ticket.reviewer;
const reviewedAt = reviewer?.reviewed_at ?? getClosedAt(ticket); const reviewedAt = reviewer?.reviewed_at ?? ticket.timestamps.updated_at;
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Resolution Details</CardTitle> <CardTitle className="text-lg">Resolution Details</CardTitle>
<CardDescription>Review record for {ticket.ticket_name}</CardDescription> <CardDescription>
Review record for {ticket.ticket_name}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
@@ -95,10 +82,7 @@ export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
value={<PersonInfo person={reviewer} size="lg" />} value={<PersonInfo person={reviewer} size="lg" />}
/> />
<MetaField <MetaField label="Reviewed Date" value={formatDateOnly(reviewedAt)} />
label="Reviewed Date"
value={formatDateOnly(reviewedAt)}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -10,6 +10,16 @@ import {
X, X,
} from 'lucide-react'; } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
@@ -85,9 +95,12 @@ function ExtensionReviewForm({
request: TicketExtensionRequest; request: TicketExtensionRequest;
}) { }) {
const [note, setNote] = useState(''); const [note, setNote] = useState('');
const [noteError, setNoteError] = useState<string | null>(null);
const [selectedAction, setSelectedAction] = useState< const [selectedAction, setSelectedAction] = useState<
'approve' | 'reject' | null 'approve' | 'reject' | null
>(null); >(null);
const [isRejectConfirmationOpen, setIsRejectConfirmationOpen] =
useState(false);
const reviewMutation = useReviewTicketExtensionMutation( const reviewMutation = useReviewTicketExtensionMutation(
ticket.id, ticket.id,
assignmentId, assignmentId,
@@ -96,14 +109,35 @@ function ExtensionReviewForm({
const extensionDays = getExtensionDays(request); const extensionDays = getExtensionDays(request);
const submitDecision = async (action: 'approve' | 'reject') => { const submitDecision = async (action: 'approve' | 'reject') => {
const trimmedNote = note.trim();
if (action === 'reject' && !trimmedNote) {
setNoteError('Manager comments are required to reject this request.');
setIsRejectConfirmationOpen(false);
return;
}
setSelectedAction(action); setSelectedAction(action);
try { try {
await reviewMutation.mutateAsync({ action, note: note.trim() }); await reviewMutation.mutateAsync({
action,
...(trimmedNote ? { note: trimmedNote } : {}),
});
} finally { } finally {
setSelectedAction(null); setSelectedAction(null);
} }
}; };
const handleRejectRequest = () => {
if (!note.trim()) {
setNoteError('Manager comments are required to reject this request.');
return;
}
setNoteError(null);
setIsRejectConfirmationOpen(true);
};
return ( return (
<TicketActionCard icon={CalendarClock} title="Extension Request Details"> <TicketActionCard icon={CalendarClock} title="Extension Request Details">
<div className="space-y-4"> <div className="space-y-4">
@@ -142,25 +176,41 @@ function ExtensionReviewForm({
</section> </section>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="extension-manager-note"> <div className="space-y-0.5">
Add Manager Comments{' '} <Label htmlFor="extension-manager-note">Add Manager Comments</Label>
<span className="text-muted-foreground">(Optional)</span> <p className="text-xs text-muted-foreground">
</Label> Required for rejection and optional for approval.
</p>
</div>
<div className="relative"> <div className="relative">
<Textarea <Textarea
id="extension-manager-note" id="extension-manager-note"
value={note} value={note}
onChange={(event) => onChange={(event) => {
setNote(event.target.value.slice(0, MAX_NOTE_LENGTH)) const nextNote = event.target.value.slice(0, MAX_NOTE_LENGTH);
} setNote(nextNote);
if (nextNote.trim()) setNoteError(null);
}}
placeholder="Add a note explaining your decision..." placeholder="Add a note explaining your decision..."
disabled={reviewMutation.isPending} disabled={reviewMutation.isPending}
aria-invalid={Boolean(noteError)}
aria-describedby={
noteError ? 'extension-manager-note-error' : undefined
}
className="min-h-24 resize-none pb-8" className="min-h-24 resize-none pb-8"
/> />
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground"> <span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
{note.length} / {MAX_NOTE_LENGTH} {note.length} / {MAX_NOTE_LENGTH}
</span> </span>
</div> </div>
{noteError ? (
<p
id="extension-manager-note-error"
className="text-xs text-destructive"
>
{noteError}
</p>
) : null}
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -169,7 +219,7 @@ function ExtensionReviewForm({
variant="outline" variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/5 hover:text-destructive" className="border-destructive/40 text-destructive hover:bg-destructive/5 hover:text-destructive"
disabled={reviewMutation.isPending} disabled={reviewMutation.isPending}
onClick={() => submitDecision('reject')} onClick={handleRejectRequest}
> >
{selectedAction === 'reject' ? ( {selectedAction === 'reject' ? (
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />
@@ -191,6 +241,33 @@ function ExtensionReviewForm({
Approve Extension Approve Extension
</Button> </Button>
</div> </div>
<AlertDialog
open={isRejectConfirmationOpen}
onOpenChange={setIsRejectConfirmationOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reject extension request?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to reject this extension request? This
decision cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={reviewMutation.isPending}>
No
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={reviewMutation.isPending}
onClick={() => submitDecision('reject')}
>
Yes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
</TicketActionCard> </TicketActionCard>
); );

View File

@@ -12,12 +12,11 @@ import {
TicketOverviewHeaderSkeleton, TicketOverviewHeaderSkeleton,
} from './components/TicketOverviewSkeleton'; } from './components/TicketOverviewSkeleton';
import { TicketClassActionsSkeleton } from './components/TicketClassDetailSkeleton'; import { TicketClassActionsSkeleton } from './components/TicketClassDetailSkeleton';
// import { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketAssignmentsCard } from './components/TicketAssignmentsCard'; import { TicketAssignmentsCard } from './components/TicketAssignmentsCard';
import { TicketOverviewCard } from './components/TicketOverviewCard'; import { TicketOverviewCard } from './components/TicketOverviewCard';
import { TicketStatusActions } from './components/TicketStatusActions'; import { TicketStatusActions } from './components/TicketStatusActions';
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs'; import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
import { TicketHistoryCard } from './components/TicketHistoryCard';
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents'; import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
import { import {
useTicketAssignmentsQuery, useTicketAssignmentsQuery,
@@ -101,13 +100,7 @@ export default function TicketDetailPage() {
assignmentId={activeAssignment.id} assignmentId={activeAssignment.id}
/> />
) : null} ) : null}
{/* Audit & Lifecycle Timeline is temporarily hidden. <TicketHistoryCard ticketId={ticketId} />
{activeAssignment && isClassDetailLoading ? (
<TicketClassContentSkeleton />
) : null}
{activeAssignment && !isClassDetailLoading && classDetail ? (
<TicketHistoryCard ticket={classDetail} />
) : null} */}
</div> </div>
<div className="min-w-0 space-y-3 self-start xl:min-h-0 xl:self-stretch xl:overflow-y-auto xl:overscroll-contain xl:pr-2"> <div className="min-w-0 space-y-3 self-start xl:min-h-0 xl:self-stretch xl:overflow-y-auto xl:overscroll-contain xl:pr-2">

View File

@@ -35,6 +35,9 @@ export function useTicketDetailEvents(
queryClient.refetchQueries({ queryClient.refetchQueries({
queryKey: ticketKeys.assignments(ticketId), queryKey: ticketKeys.assignments(ticketId),
}); });
queryClient.refetchQueries({
queryKey: ticketKeys.timeline(ticketId),
});
if (defectClassRef.current) { if (defectClassRef.current) {
queryClient.refetchQueries({ queryClient.refetchQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClassRef.current), queryKey: ticketKeys.classDetail(ticketId, defectClassRef.current),

View File

@@ -70,6 +70,14 @@ export function useTicketOverviewQuery(ticketId: string | undefined) {
}); });
} }
export function useTicketTimelineQuery(ticketId: string | undefined) {
return useQuery({
queryKey: ticketKeys.timeline(ticketId ?? ''),
queryFn: () => ticketService.getTicketTimeline(ticketId as string),
enabled: Boolean(ticketId),
});
}
export function useTicketClassDetailQuery( export function useTicketClassDetailQuery(
ticketId: string | undefined, ticketId: string | undefined,
defectClass: string | undefined, defectClass: string | undefined,
@@ -296,6 +304,9 @@ export function useReviewDetectionRepairProofMutation({
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId), queryKey: ticketKeys.overview(ticketId),
}), }),
queryClient.invalidateQueries({
queryKey: ticketKeys.timeline(ticketId),
}),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }), queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
]); ]);
}, },
@@ -326,19 +337,6 @@ export function useAssignableTicketUsersQuery(enabled: boolean) {
}); });
} }
function mergeTicketDetailResponse(
current: TicketDetail | undefined,
next: TicketDetail,
) {
if (!current) return next;
return {
...current,
...next,
history: next.history ?? current.history,
};
}
export function useAssignTicketMutation(ticketId: string, videoId?: string) { export function useAssignTicketMutation(ticketId: string, videoId?: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -351,6 +349,9 @@ export function useAssignTicketMutation(ticketId: string, videoId?: string) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId), queryKey: ticketKeys.overview(ticketId),
}), }),
queryClient.invalidateQueries({
queryKey: ticketKeys.timeline(ticketId),
}),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }), queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
...(videoId ...(videoId
? [ ? [
@@ -397,6 +398,9 @@ export function useRequestTicketExtensionMutation(
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId), queryKey: ticketKeys.assignments(ticketId),
}); });
queryClient.invalidateQueries({
queryKey: ticketKeys.timeline(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
}, },
onError: () => toast.error('Failed to request an extension'), onError: () => toast.error('Failed to request an extension'),
@@ -427,6 +431,9 @@ export function useReviewTicketExtensionMutation(
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId), queryKey: ticketKeys.assignments(ticketId),
}); });
queryClient.invalidateQueries({
queryKey: ticketKeys.timeline(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
}, },
onError: () => toast.error('Failed to review extension request'), onError: () => toast.error('Failed to review extension request'),
@@ -442,8 +449,11 @@ export function useCloseTicketMutation(ticketId: string) {
toast.success('Ticket closed'); toast.success('Ticket closed');
queryClient.setQueryData<TicketDetail>( queryClient.setQueryData<TicketDetail>(
ticketKeys.classDetail(ticketId, payload.defect_class), ticketKeys.classDetail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket), ticket,
); );
queryClient.invalidateQueries({
queryKey: ticketKeys.timeline(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
}, },
onError: () => toast.error('Failed to close ticket'), onError: () => toast.error('Failed to close ticket'),

View File

@@ -11,6 +11,8 @@ export const ticketKeys = {
details: () => [...ticketKeys.all, 'detail'] as const, details: () => [...ticketKeys.all, 'detail'] as const,
overview: (ticketId: string) => overview: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'overview'] as const, [...ticketKeys.details(), ticketId, 'overview'] as const,
timeline: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'timeline'] as const,
classDetail: (ticketId: string, defectClass: string) => classDetail: (ticketId: string, defectClass: string) =>
[...ticketKeys.details(), ticketId, 'class', defectClass] as const, [...ticketKeys.details(), ticketId, 'class', defectClass] as const,
classDetectionLists: (videoId: string) => classDetectionLists: (videoId: string) =>

View File

@@ -65,6 +65,7 @@ export const API_ROUTES = {
TICKETS: { TICKETS: {
BASE: '/biz/api/v1/tickets', BASE: '/biz/api/v1/tickets',
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`, DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
TIMELINE: (id: string) => `/biz/api/v1/tickets/${id}/timeline`,
CLASS_DETAIL: (id: string, defectClass: string) => CLASS_DETAIL: (id: string, defectClass: string) =>
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`, `/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`, DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,

View File

@@ -16,6 +16,7 @@ import type {
TicketDefectClassesResponse, TicketDefectClassesResponse,
TicketListParams, TicketListParams,
TicketListResponse, TicketListResponse,
TicketTimelineResponse,
} from '@/types'; } from '@/types';
type TicketAssignmentsApiResponse = type TicketAssignmentsApiResponse =
@@ -52,6 +53,15 @@ export const ticketService = {
return response.data; return response.data;
}, },
getTicketTimeline: async (
ticketId: string,
): Promise<TicketTimelineResponse> => {
const response = await axiosClient.get<TicketTimelineResponse>(
API_ROUTES.TICKETS.TIMELINE(ticketId),
);
return response.data;
},
getTicketClassDetail: async ( getTicketClassDetail: async (
ticketId: string, ticketId: string,
defectClass: string, defectClass: string,

View File

@@ -46,7 +46,7 @@ export interface RequestTicketExtensionPayload {
export interface ReviewTicketExtensionPayload { export interface ReviewTicketExtensionPayload {
action: 'approve' | 'reject'; action: 'approve' | 'reject';
note: string; note?: string;
} }
export interface TicketExtensionRequestActor { export interface TicketExtensionRequestActor {

View File

@@ -9,29 +9,6 @@ export interface TicketActor {
avatar_url?: string | null; avatar_url?: string | null;
} }
export type TicketHistorySource = 'system' | 'user';
export interface TicketHistoryNote {
kind: string;
author: TicketActor | null;
text: string;
}
export interface TicketHistoryItem {
id: number;
event_type: string;
title: string;
source: TicketHistorySource;
assignment_id: number | null;
defect_class: string | null;
from_class_status: TicketDefectClassTicketStatus | null;
to_class_status: TicketDefectClassTicketStatus | null;
actor: TicketActor | null;
target_user: TicketActor | null;
note: TicketHistoryNote | null;
created_at: string;
}
export interface TicketStatusMetadata { export interface TicketStatusMetadata {
reason: string | null; reason: string | null;
label: string | null; label: string | null;
@@ -180,5 +157,4 @@ export interface TicketDetail {
ai_result: TicketAiResult | null; ai_result: TicketAiResult | null;
tenant: TicketTenant | null; tenant: TicketTenant | null;
timestamps: TicketTimestamps; timestamps: TicketTimestamps;
history: TicketHistoryItem[];
} }

View File

@@ -4,3 +4,4 @@ export * from './detail';
export * from './actions'; export * from './actions';
export * from './defect-class'; export * from './defect-class';
export * from './events'; export * from './events';
export * from './timeline';

View File

@@ -0,0 +1,60 @@
export type UTCDateTimeString = string;
export type TicketTimelineSource = 'system' | 'user';
export type TicketTimelineNoteKind =
| 'system_note'
| 'user_note'
| 'repair_note'
| 'review_comment';
export type TicketTimelineEventType =
| 'ai_detected'
| 'ticket_created'
| 'ticket_fully_assigned'
| 'ticket_completed'
| 'ticket_closed'
| 'assignment_added'
| 'assignment_replaced'
| 'assignment_updated'
| 'assignment_deleted'
| 'repair_started'
| 'repair_submitted'
| 'assignment_approve'
| 'assignment_reject'
| 'extension_requested'
| 'extension_approved'
| 'extension_rejected'
| 'extension_cancelled'
| 'lot_submitted_for_review'
| 'lot_resubmitted_for_review'
| 'lot_returned_for_correction'
| 'lot_approved';
export interface TicketTimelineUser {
user_id: number | null;
name: string | null;
email: string | null;
}
export interface TicketTimelineNote {
kind: TicketTimelineNoteKind;
author: TicketTimelineUser;
text: string;
}
export interface TicketTimelineItem {
id: number;
event_type: TicketTimelineEventType | null;
title: string;
source: TicketTimelineSource;
assignment_id: number | null;
actor: TicketTimelineUser;
target_user: TicketTimelineUser | null;
note: TicketTimelineNote | null;
created_at: UTCDateTimeString;
}
export interface TicketTimelineResponse {
history: TicketTimelineItem[];
}