feat(ticket): add audit timeline and safeguard extension rejection
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user