+
{getSourceLabel(item.source)}
- {item.title}
+
+ {item.title}
+
{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 isAssigned = item.event_type === 'assigned';
+ const showTargetUser =
+ item.target_user && !isSameUser(item.actor, item.target_user);
return (
-
{item.title}
+
+ {item.title}
+
- {isAssigned && item.target_user ? (
+ {showTargetUser && item.target_user ? (
-
Assigned to
+
+ {getTargetLabel(item.event_type)}
+
) : null}
@@ -84,17 +146,54 @@ function UserHistoryEntry({ item }: { item: TicketHistoryItem }) {
);
}
-export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
- const history = ticket.history ?? [];
+function TimelineHeader() {
+ return (
+
+
+
Audit & Lifecycle Timeline
+
+ );
+}
+
+export function TicketHistoryCard({ ticketId }: { ticketId: string }) {
+ const timelineQuery = useTicketTimelineQuery(ticketId);
+
+ if (timelineQuery.isLoading) {
+ return
;
+ }
+
+ const history = timelineQuery.data?.history ?? [];
return (
-
-
-
Audit & Lifecycle Timeline
-
+
- {history.length ? (
+ {timelineQuery.isError ? (
+
+
+ Failed to load the ticket timeline.
+
+
+
+ ) : history.length ? (
{history.map((item, index) => (
}
>
- <>
- {ticket.assignment_status === 'assigned' ? (
-
- ) : null}
-
- >
+
);
}
- if (ticket.assignment_status === 'unassigned') {
- return null;
- }
-
- if (ticket.assignment_status === 'assigned') {
- return (
- }
- >
-
-
- );
- }
-
- if (ticket.assignment_status === 'under_review') {
- return null;
- }
-
- if (
- ticket.assignment_status === 'approved' ||
- ticket.assignment_status === 'rejected'
- ) {
- return ;
- }
-
- return ;
+ return null;
}
export function TicketStatusActions({
diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketTimelineSkeleton.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketTimelineSkeleton.tsx
new file mode 100644
index 0000000..c4f04e9
--- /dev/null
+++ b/src/app/(modules)/ticket/[ticketId]/components/TicketTimelineSkeleton.tsx
@@ -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 (
+
+
+
+
+ }
+ marker={
}
+ >
+
+ {index === 0 ?
: null}
+
+ {index === 0 ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ );
+}
+
+export function TicketTimelineSkeleton() {
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/(modules)/ticket/[ticketId]/components/actions/ClosedTicketAction.tsx b/src/app/(modules)/ticket/[ticketId]/components/actions/ClosedTicketAction.tsx
index f99bebc..61ec5dd 100644
--- a/src/app/(modules)/ticket/[ticketId]/components/actions/ClosedTicketAction.tsx
+++ b/src/app/(modules)/ticket/[ticketId]/components/actions/ClosedTicketAction.tsx
@@ -16,21 +16,6 @@ import { cn } from '@/lib/utils';
import type { TicketDetail } from '@/types';
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({
label,
value,
@@ -79,13 +64,15 @@ function FinalCommentsPanel({
export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
const reviewer = ticket.reviewer;
- const reviewedAt = reviewer?.reviewed_at ?? getClosedAt(ticket);
+ const reviewedAt = reviewer?.reviewed_at ?? ticket.timestamps.updated_at;
return (
Resolution Details
- Review record for {ticket.ticket_name}
+
+ Review record for {ticket.ticket_name}
+
@@ -95,10 +82,7 @@ export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
value={}
/>
-
+
diff --git a/src/app/(modules)/ticket/[ticketId]/components/actions/ReviewExtensionRequestAction.tsx b/src/app/(modules)/ticket/[ticketId]/components/actions/ReviewExtensionRequestAction.tsx
index 9da0b5b..e0ae635 100644
--- a/src/app/(modules)/ticket/[ticketId]/components/actions/ReviewExtensionRequestAction.tsx
+++ b/src/app/(modules)/ticket/[ticketId]/components/actions/ReviewExtensionRequestAction.tsx
@@ -10,6 +10,16 @@ import {
X,
} from 'lucide-react';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
@@ -85,9 +95,12 @@ function ExtensionReviewForm({
request: TicketExtensionRequest;
}) {
const [note, setNote] = useState('');
+ const [noteError, setNoteError] = useState
(null);
const [selectedAction, setSelectedAction] = useState<
'approve' | 'reject' | null
>(null);
+ const [isRejectConfirmationOpen, setIsRejectConfirmationOpen] =
+ useState(false);
const reviewMutation = useReviewTicketExtensionMutation(
ticket.id,
assignmentId,
@@ -96,14 +109,35 @@ function ExtensionReviewForm({
const extensionDays = getExtensionDays(request);
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);
try {
- await reviewMutation.mutateAsync({ action, note: note.trim() });
+ await reviewMutation.mutateAsync({
+ action,
+ ...(trimmedNote ? { note: trimmedNote } : {}),
+ });
} finally {
setSelectedAction(null);
}
};
+ const handleRejectRequest = () => {
+ if (!note.trim()) {
+ setNoteError('Manager comments are required to reject this request.');
+ return;
+ }
+
+ setNoteError(null);
+ setIsRejectConfirmationOpen(true);
+ };
+
return (
@@ -142,25 +176,41 @@ function ExtensionReviewForm({
-
+
+
+
+ Required for rejection and optional for approval.
+
+
+ {noteError ? (
+
+ {noteError}
+
+ ) : null}
@@ -169,7 +219,7 @@ function ExtensionReviewForm({
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/5 hover:text-destructive"
disabled={reviewMutation.isPending}
- onClick={() => submitDecision('reject')}
+ onClick={handleRejectRequest}
>
{selectedAction === 'reject' ? (
@@ -191,6 +241,33 @@ function ExtensionReviewForm({
Approve Extension
+
+
+
+
+ Reject extension request?
+
+ Are you sure you want to reject this extension request? This
+ decision cannot be undone.
+
+
+
+
+ No
+
+ submitDecision('reject')}
+ >
+ Yes
+
+
+
+
);
diff --git a/src/app/(modules)/ticket/[ticketId]/page.tsx b/src/app/(modules)/ticket/[ticketId]/page.tsx
index 4c569ec..c78beed 100644
--- a/src/app/(modules)/ticket/[ticketId]/page.tsx
+++ b/src/app/(modules)/ticket/[ticketId]/page.tsx
@@ -12,12 +12,11 @@ import {
TicketOverviewHeaderSkeleton,
} from './components/TicketOverviewSkeleton';
import { TicketClassActionsSkeleton } from './components/TicketClassDetailSkeleton';
-
-// import { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketAssignmentsCard } from './components/TicketAssignmentsCard';
import { TicketOverviewCard } from './components/TicketOverviewCard';
import { TicketStatusActions } from './components/TicketStatusActions';
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
+import { TicketHistoryCard } from './components/TicketHistoryCard';
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
import {
useTicketAssignmentsQuery,
@@ -101,13 +100,7 @@ export default function TicketDetailPage() {
assignmentId={activeAssignment.id}
/>
) : null}
- {/* Audit & Lifecycle Timeline is temporarily hidden.
- {activeAssignment && isClassDetailLoading ? (
-
- ) : null}
- {activeAssignment && !isClassDetailLoading && classDetail ? (
-
- ) : null} */}
+
diff --git a/src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts b/src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts
index 823c023..e7ba85c 100644
--- a/src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts
+++ b/src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts
@@ -35,6 +35,9 @@ export function useTicketDetailEvents(
queryClient.refetchQueries({
queryKey: ticketKeys.assignments(ticketId),
});
+ queryClient.refetchQueries({
+ queryKey: ticketKeys.timeline(ticketId),
+ });
if (defectClassRef.current) {
queryClient.refetchQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClassRef.current),
diff --git a/src/app/(modules)/ticket/hooks/useTicketQueries.ts b/src/app/(modules)/ticket/hooks/useTicketQueries.ts
index 015f9f8..8fe2cdc 100644
--- a/src/app/(modules)/ticket/hooks/useTicketQueries.ts
+++ b/src/app/(modules)/ticket/hooks/useTicketQueries.ts
@@ -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(
ticketId: string | undefined,
defectClass: string | undefined,
@@ -296,6 +304,9 @@ export function useReviewDetectionRepairProofMutation({
queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId),
}),
+ queryClient.invalidateQueries({
+ queryKey: ticketKeys.timeline(ticketId),
+ }),
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) {
const queryClient = useQueryClient();
@@ -351,6 +349,9 @@ export function useAssignTicketMutation(ticketId: string, videoId?: string) {
queryClient.invalidateQueries({
queryKey: ticketKeys.overview(ticketId),
}),
+ queryClient.invalidateQueries({
+ queryKey: ticketKeys.timeline(ticketId),
+ }),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
...(videoId
? [
@@ -397,6 +398,9 @@ export function useRequestTicketExtensionMutation(
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
});
+ queryClient.invalidateQueries({
+ queryKey: ticketKeys.timeline(ticketId),
+ });
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to request an extension'),
@@ -427,6 +431,9 @@ export function useReviewTicketExtensionMutation(
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
});
+ queryClient.invalidateQueries({
+ queryKey: ticketKeys.timeline(ticketId),
+ });
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to review extension request'),
@@ -442,8 +449,11 @@ export function useCloseTicketMutation(ticketId: string) {
toast.success('Ticket closed');
queryClient.setQueryData(
ticketKeys.classDetail(ticketId, payload.defect_class),
- (current) => mergeTicketDetailResponse(current, ticket),
+ ticket,
);
+ queryClient.invalidateQueries({
+ queryKey: ticketKeys.timeline(ticketId),
+ });
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to close ticket'),
diff --git a/src/app/(modules)/ticket/queries/ticketKeys.ts b/src/app/(modules)/ticket/queries/ticketKeys.ts
index cad008c..79de18c 100644
--- a/src/app/(modules)/ticket/queries/ticketKeys.ts
+++ b/src/app/(modules)/ticket/queries/ticketKeys.ts
@@ -11,6 +11,8 @@ export const ticketKeys = {
details: () => [...ticketKeys.all, 'detail'] as const,
overview: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'overview'] as const,
+ timeline: (ticketId: string) =>
+ [...ticketKeys.details(), ticketId, 'timeline'] as const,
classDetail: (ticketId: string, defectClass: string) =>
[...ticketKeys.details(), ticketId, 'class', defectClass] as const,
classDetectionLists: (videoId: string) =>
diff --git a/src/constants/apiRoutes.ts b/src/constants/apiRoutes.ts
index 74bd155..641138a 100644
--- a/src/constants/apiRoutes.ts
+++ b/src/constants/apiRoutes.ts
@@ -65,6 +65,7 @@ export const API_ROUTES = {
TICKETS: {
BASE: '/biz/api/v1/tickets',
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
+ TIMELINE: (id: string) => `/biz/api/v1/tickets/${id}/timeline`,
CLASS_DETAIL: (id: string, defectClass: string) =>
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,
diff --git a/src/services/api/ticket.service.ts b/src/services/api/ticket.service.ts
index 4daad34..5d20ed3 100644
--- a/src/services/api/ticket.service.ts
+++ b/src/services/api/ticket.service.ts
@@ -16,6 +16,7 @@ import type {
TicketDefectClassesResponse,
TicketListParams,
TicketListResponse,
+ TicketTimelineResponse,
} from '@/types';
type TicketAssignmentsApiResponse =
@@ -52,6 +53,15 @@ export const ticketService = {
return response.data;
},
+ getTicketTimeline: async (
+ ticketId: string,
+ ): Promise => {
+ const response = await axiosClient.get(
+ API_ROUTES.TICKETS.TIMELINE(ticketId),
+ );
+ return response.data;
+ },
+
getTicketClassDetail: async (
ticketId: string,
defectClass: string,
diff --git a/src/types/ticket/actions.ts b/src/types/ticket/actions.ts
index b05c72a..940be53 100644
--- a/src/types/ticket/actions.ts
+++ b/src/types/ticket/actions.ts
@@ -46,7 +46,7 @@ export interface RequestTicketExtensionPayload {
export interface ReviewTicketExtensionPayload {
action: 'approve' | 'reject';
- note: string;
+ note?: string;
}
export interface TicketExtensionRequestActor {
diff --git a/src/types/ticket/detail.ts b/src/types/ticket/detail.ts
index 1c14d6b..a15219f 100644
--- a/src/types/ticket/detail.ts
+++ b/src/types/ticket/detail.ts
@@ -9,29 +9,6 @@ export interface TicketActor {
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 {
reason: string | null;
label: string | null;
@@ -180,5 +157,4 @@ export interface TicketDetail {
ai_result: TicketAiResult | null;
tenant: TicketTenant | null;
timestamps: TicketTimestamps;
- history: TicketHistoryItem[];
}
diff --git a/src/types/ticket/index.ts b/src/types/ticket/index.ts
index 4f98cfd..ebbaa7c 100644
--- a/src/types/ticket/index.ts
+++ b/src/types/ticket/index.ts
@@ -4,3 +4,4 @@ export * from './detail';
export * from './actions';
export * from './defect-class';
export * from './events';
+export * from './timeline';
diff --git a/src/types/ticket/timeline.ts b/src/types/ticket/timeline.ts
new file mode 100644
index 0000000..a18c92f
--- /dev/null
+++ b/src/types/ticket/timeline.ts
@@ -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[];
+}