feat(ticket): redesign audit timeline with structured history events
This commit is contained in:
@@ -2,87 +2,76 @@
|
||||
|
||||
import { History, MessageSquareText } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PersonInfo } from '@/components/person-avatar';
|
||||
import { Timeline, TimelineItem } from '@/components/ui/timeline';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TicketDetail, TicketHistoryItem, TicketStatus } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
import type { TicketDetail, TicketHistoryItem } from '@/types';
|
||||
import { formatTimelineDateTime } from '@/utils/date';
|
||||
import { getPersonLabel } from '@/utils/person';
|
||||
|
||||
function getActorLabel(item: TicketHistoryItem) {
|
||||
return item.actor?.name || item.actor?.email || 'System';
|
||||
}
|
||||
|
||||
function getLifecycleTitle(status: TicketStatus) {
|
||||
const titles: Record<TicketStatus, string> = {
|
||||
unassigned: 'Awaiting Assignment',
|
||||
assigned: 'Dispatched to Maintenance',
|
||||
under_review: 'Maintenance Finalized',
|
||||
closed: 'Ticket Closed',
|
||||
};
|
||||
|
||||
return titles[status];
|
||||
}
|
||||
|
||||
function HistoryEntry({
|
||||
item,
|
||||
isLast,
|
||||
}: {
|
||||
item: TicketHistoryItem;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
const isClosed = item.to_status === 'closed';
|
||||
function TimelineDate({ value }: { value: string }) {
|
||||
const { date, time } = formatTimelineDateTime(value);
|
||||
|
||||
return (
|
||||
<div className="relative flex gap-4 pb-8 last:pb-0">
|
||||
{!isLast ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-6 left-2.75 h-[calc(100%-10px)] w-px bg-border"
|
||||
/>
|
||||
) : null}
|
||||
<time dateTime={value} className="block tabular-nums">
|
||||
<span className="block text-sm font-semibold text-foreground">{date}</span>
|
||||
<span className="block text-xs text-muted-foreground">{time}</span>
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'relative z-10 mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border-2 bg-background',
|
||||
isClosed
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-primary/60',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 rounded-full bg-primary',
|
||||
isClosed &&
|
||||
'h-1.5 w-2.5 -rotate-45 rounded-none border-b-2 border-l-2 border-current bg-transparent',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
function HistoryNote({ item }: { item: TicketHistoryItem }) {
|
||||
const noteText = item.note?.text?.trim();
|
||||
if (!noteText) return null;
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{getLifecycleTitle(item.to_status)}
|
||||
</h3>
|
||||
<time
|
||||
dateTime={item.created_at}
|
||||
className="text-xs font-semibold text-muted-foreground tabular-nums"
|
||||
>
|
||||
{formatDate(item.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Updated By{' '}
|
||||
<span className="text-foreground/90">{getActorLabel(item)}</span>
|
||||
</p>
|
||||
|
||||
{item.note ? (
|
||||
<div className="flex gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm leading-relaxed text-foreground">
|
||||
<MessageSquareText className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<p>{item.note}</p>
|
||||
</div>
|
||||
) : null}
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-amber-300/80 bg-amber-50/90 px-3 py-2.5 dark:border-amber-500/30 dark:bg-amber-500/10">
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-amber-950/80 dark:text-amber-100/90">
|
||||
{noteText}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemHistoryEntry({ item }: { item: TicketHistoryItem }) {
|
||||
const noteText = item.note?.text?.trim();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-violet-300/80 bg-violet-50/90 px-4 py-3.5 dark:border-violet-400/25 dark:bg-violet-500/8">
|
||||
<span className="inline-flex rounded-full bg-violet-200/90 px-2.5 py-0.5 text-[10px] font-semibold tracking-wider text-violet-700 uppercase dark:bg-violet-500/20 dark:text-violet-300">
|
||||
System Generated
|
||||
</span>
|
||||
<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}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserHistoryEntry({ item }: { item: TicketHistoryItem }) {
|
||||
const noteText = item.note?.text?.trim();
|
||||
const isAssigned = item.event_type === 'assigned';
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<h3 className="mb-3 text-sm font-semibold text-foreground">{item.title}</h3>
|
||||
<PersonInfo person={item.actor} />
|
||||
{isAssigned && item.target_user ? (
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Assigned to</p>
|
||||
<PersonInfo person={item.target_user} />
|
||||
</div>
|
||||
) : null}
|
||||
{noteText ? <HistoryNote item={item} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -91,33 +80,48 @@ export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
|
||||
const history = ticket.history ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="size-5 text-primary" />
|
||||
Audit & Lifecycle Timeline
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<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>
|
||||
|
||||
<CardContent>
|
||||
{history.length ? (
|
||||
<div className="pl-0.5">
|
||||
{history.map((item, index) => (
|
||||
<HistoryEntry
|
||||
key={item.id}
|
||||
item={item}
|
||||
isLast={index === history.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
No timeline entries yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{history.length ? (
|
||||
<Timeline>
|
||||
{history.map((item, index) => (
|
||||
<TimelineItem
|
||||
key={item.id}
|
||||
layout="left"
|
||||
isLast={index === history.length - 1}
|
||||
opposite={<TimelineDate value={item.created_at} />}
|
||||
marker={
|
||||
<span className="flex size-4 items-center justify-center rounded-full border bg-background shadow-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 rounded-full',
|
||||
item.source === 'system'
|
||||
? 'bg-muted-foreground'
|
||||
: 'bg-primary',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{item.source === 'system' ? (
|
||||
<SystemHistoryEntry item={item} />
|
||||
) : (
|
||||
<UserHistoryEntry item={item} />
|
||||
)}
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
No timeline entries yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user