diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketClassDetailSkeleton.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketClassDetailSkeleton.tsx index 769023b..447d87b 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/TicketClassDetailSkeleton.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/TicketClassDetailSkeleton.tsx @@ -1,74 +1,5 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'; 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 TicketClassContentSkeleton() { - return ( -
-
- - -
- - - {Array.from({ length: 3 }).map((_, index) => ( - - ))} - -
- ); -} export function TicketClassActionsSkeleton() { return ( diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketHistoryCard.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketHistoryCard.tsx index 95037a5..0078ad1 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/TicketHistoryCard.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/TicketHistoryCard.tsx @@ -1,28 +1,56 @@ 'use client'; -import { History, MessageSquareText } from 'lucide-react'; +import { History, MessageSquareText, RefreshCw } from 'lucide-react'; import { PersonInfo } from '@/components/person-avatar'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { Timeline, TimelineItem } from '@/components/ui/timeline'; 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 { getPersonLabel } from '@/utils/person'; +import { useTicketTimelineQuery } from '../../hooks/useTicketQueries'; +import { TicketTimelineSkeleton } from './TicketTimelineSkeleton'; + function TimelineDate({ value }: { value: string }) { const { date, time } = formatTimelineDateTime(value); return ( ); } -function HistoryNote({ item }: { item: TicketHistoryItem }) { - const noteText = item.note?.text?.trim(); +function getNoteKindLabel(kind: TicketTimelineNoteKind) { + 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; return ( @@ -30,7 +58,7 @@ function HistoryNote({ item }: { item: TicketHistoryItem }) {
- Note by {getPersonLabel(item.note?.author ?? item.actor)} + {getNoteKindLabel(note.kind)} by {getPersonLabel(note.author)}

@@ -40,22 +68,21 @@ function HistoryNote({ item }: { item: TicketHistoryItem }) { ); } -function getSourceLabel(source: TicketHistorySource) { +function getSourceLabel(source: TicketTimelineSource) { return source === 'system' ? 'System Generated' : 'User Action'; } -function SystemHistoryEntry({ item }: { item: TicketHistoryItem }) { +function SystemHistoryEntry({ item }: { item: TicketTimelineItem }) { const noteText = item.note?.text?.trim(); return (

- + {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. +

+