feat(ticket): add audit timeline and safeguard extension rejection
This commit is contained in:
@@ -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 (
|
||||
<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() {
|
||||
return (
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
<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" />
|
||||
<span>
|
||||
Note by {getPersonLabel(item.note?.author ?? item.actor)}
|
||||
{getNoteKindLabel(note.kind)} by {getPersonLabel(note.author)}
|
||||
</span>
|
||||
</div>
|
||||
<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';
|
||||
}
|
||||
|
||||
function SystemHistoryEntry({ item }: { item: TicketHistoryItem }) {
|
||||
function SystemHistoryEntry({ item }: { item: TicketTimelineItem }) {
|
||||
const noteText = item.note?.text?.trim();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5">
|
||||
<Badge
|
||||
|
||||
className="rounded-lg px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase"
|
||||
>
|
||||
<Badge className="rounded-lg px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase">
|
||||
{getSourceLabel(item.source)}
|
||||
</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 ? (
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{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 (
|
||||
<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} />
|
||||
{isAssigned && item.target_user ? (
|
||||
{showTargetUser && item.target_user ? (
|
||||
<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} />
|
||||
</div>
|
||||
) : null}
|
||||
@@ -84,17 +146,54 @@ function UserHistoryEntry({ item }: { item: TicketHistoryItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
|
||||
const history = ticket.history ?? [];
|
||||
function TimelineHeader() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="size-5 text-primary" />
|
||||
<h2 className="text-base font-semibold">Audit & Lifecycle Timeline</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="size-5 text-primary" />
|
||||
<h2 className="text-base font-semibold">Audit & Lifecycle Timeline</h2>
|
||||
</div>
|
||||
<TimelineHeader />
|
||||
|
||||
{history.length ? (
|
||||
{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>
|
||||
{history.map((item, index) => (
|
||||
<TimelineItem
|
||||
|
||||
@@ -4,8 +4,6 @@ import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import type { TicketAssignmentSummary, TicketDetail } from '@/types';
|
||||
|
||||
import { AssignedContractorAction } from './actions/AssignedContractorAction';
|
||||
import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
||||
import { NoTicketAction } from './actions/NoTicketAction';
|
||||
import { RequestExtensionAction } from './actions/RequestExtensionAction';
|
||||
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
||||
@@ -36,46 +34,15 @@ function getTicketAction(
|
||||
</PermissionGuard>
|
||||
}
|
||||
>
|
||||
<>
|
||||
{ticket.assignment_status === 'assigned' ? (
|
||||
<AssignedContractorAction ticket={ticket} />
|
||||
) : null}
|
||||
<ReviewExtensionRequestAction
|
||||
ticket={ticket}
|
||||
assignmentId={assignment.id}
|
||||
/>
|
||||
</>
|
||||
<ReviewExtensionRequestAction
|
||||
ticket={ticket}
|
||||
assignmentId={assignment.id}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
if (ticket.assignment_status === 'unassigned') {
|
||||
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 />;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TicketStatusActions({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Resolution Details</CardTitle>
|
||||
<CardDescription>Review record for {ticket.ticket_name}</CardDescription>
|
||||
<CardDescription>
|
||||
Review record for {ticket.ticket_name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
@@ -95,10 +82,7 @@ export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||
value={<PersonInfo person={reviewer} size="lg" />}
|
||||
/>
|
||||
|
||||
<MetaField
|
||||
label="Reviewed Date"
|
||||
value={formatDateOnly(reviewedAt)}
|
||||
/>
|
||||
<MetaField label="Reviewed Date" value={formatDateOnly(reviewedAt)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<TicketActionCard icon={CalendarClock} title="Extension Request Details">
|
||||
<div className="space-y-4">
|
||||
@@ -142,25 +176,41 @@ function ExtensionReviewForm({
|
||||
</section>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="extension-manager-note">
|
||||
Add Manager Comments{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</Label>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="extension-manager-note">Add Manager Comments</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required for rejection and optional for approval.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
id="extension-manager-note"
|
||||
value={note}
|
||||
onChange={(event) =>
|
||||
setNote(event.target.value.slice(0, MAX_NOTE_LENGTH))
|
||||
}
|
||||
onChange={(event) => {
|
||||
const nextNote = event.target.value.slice(0, MAX_NOTE_LENGTH);
|
||||
setNote(nextNote);
|
||||
if (nextNote.trim()) setNoteError(null);
|
||||
}}
|
||||
placeholder="Add a note explaining your decision..."
|
||||
disabled={reviewMutation.isPending}
|
||||
aria-invalid={Boolean(noteError)}
|
||||
aria-describedby={
|
||||
noteError ? 'extension-manager-note-error' : undefined
|
||||
}
|
||||
className="min-h-24 resize-none pb-8"
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
|
||||
{note.length} / {MAX_NOTE_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
{noteError ? (
|
||||
<p
|
||||
id="extension-manager-note-error"
|
||||
className="text-xs text-destructive"
|
||||
>
|
||||
{noteError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -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' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
@@ -191,6 +241,33 @@ function ExtensionReviewForm({
|
||||
Approve Extension
|
||||
</Button>
|
||||
</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>
|
||||
</TicketActionCard>
|
||||
);
|
||||
|
||||
@@ -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 ? (
|
||||
<TicketClassContentSkeleton />
|
||||
) : null}
|
||||
{activeAssignment && !isClassDetailLoading && classDetail ? (
|
||||
<TicketHistoryCard ticket={classDetail} />
|
||||
) : null} */}
|
||||
<TicketHistoryCard ticketId={ticketId} />
|
||||
</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">
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<TicketDetail>(
|
||||
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'),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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<TicketTimelineResponse> => {
|
||||
const response = await axiosClient.get<TicketTimelineResponse>(
|
||||
API_ROUTES.TICKETS.TIMELINE(ticketId),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTicketClassDetail: async (
|
||||
ticketId: string,
|
||||
defectClass: string,
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface RequestTicketExtensionPayload {
|
||||
|
||||
export interface ReviewTicketExtensionPayload {
|
||||
action: 'approve' | 'reject';
|
||||
note: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface TicketExtensionRequestActor {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './detail';
|
||||
export * from './actions';
|
||||
export * from './defect-class';
|
||||
export * from './events';
|
||||
export * from './timeline';
|
||||
|
||||
60
src/types/ticket/timeline.ts
Normal file
60
src/types/ticket/timeline.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user