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 { 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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,46 +34,15 @@ 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({
|
||||||
|
|||||||
@@ -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 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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
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