235 lines
7.1 KiB
TypeScript
235 lines
7.1 KiB
TypeScript
'use client';
|
|
|
|
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 {
|
|
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-xs text-muted-foreground">{time}</span>
|
|
</time>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<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>
|
|
{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">
|
|
{noteText}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getSourceLabel(source: TicketTimelineSource) {
|
|
return source === 'system' ? 'System Generated' : 'User Action';
|
|
}
|
|
|
|
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">
|
|
{getSourceLabel(item.source)}
|
|
</Badge>
|
|
<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 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 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>
|
|
<PersonInfo person={item.actor} />
|
|
{showTargetUser && item.target_user ? (
|
|
<div className="mt-3 space-y-1">
|
|
<p className="text-xs text-muted-foreground">
|
|
{getTargetLabel(item.event_type)}
|
|
</p>
|
|
<PersonInfo person={item.target_user} />
|
|
</div>
|
|
) : null}
|
|
{noteText ? <HistoryNote item={item} /> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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">
|
|
<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>
|
|
{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>
|
|
);
|
|
}
|