feat(ticket): redesign audit timeline with structured history events

This commit is contained in:
2026-07-01 22:23:49 +05:30
parent 1a94ff80e6
commit c73ba0a01f
13 changed files with 390 additions and 243 deletions

View File

@@ -0,0 +1,72 @@
'use client';
import { UserRound } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
import type { TicketActor } from '@/types';
import { getPersonLabel } from '@/utils/person';
type PersonAvatarProps = {
person: TicketActor | null | undefined;
size?: 'default' | 'sm' | 'lg';
className?: string;
};
export function PersonAvatar({
person,
size = 'default',
className,
}: PersonAvatarProps) {
const avatarUrl = person?.avatar_url?.trim();
return (
<Avatar size={size} className={className}>
{avatarUrl ? (
<AvatarImage src={avatarUrl} alt={getPersonLabel(person)} />
) : null}
<AvatarFallback className="bg-muted text-muted-foreground">
<UserRound
className={cn(
'shrink-0',
size === 'sm' ? 'size-3.5' : size === 'lg' ? 'size-5' : 'size-4',
)}
aria-hidden
/>
</AvatarFallback>
</Avatar>
);
}
type PersonInfoProps = {
person: TicketActor | null | undefined;
size?: 'default' | 'sm' | 'lg';
className?: string;
};
export function PersonInfo({
person,
size = 'sm',
className,
}: PersonInfoProps) {
if (!person) {
return <p className="text-sm text-muted-foreground">-</p>;
}
const name = person.name?.trim();
const email = person.email?.trim();
return (
<div className={cn('flex min-w-0 items-center gap-2.5', className)}>
<PersonAvatar person={person} size={size} />
<div className="min-w-0">
<p className="truncate text-sm text-foreground">
{name || email || 'Unknown'}
</p>
{name && email ? (
<p className="truncate text-xs text-muted-foreground">{email}</p>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Timeline({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot="timeline"
className={cn('flex w-full flex-col', className)}
{...props}
/>
);
}
function TimelineItem({
className,
marker,
opposite,
layout = 'alternate',
side = 'right',
isLast = false,
children,
...props
}: Omit<React.ComponentProps<'li'>, 'children'> & {
marker: React.ReactNode;
opposite: React.ReactNode;
layout?: 'left' | 'alternate';
side?: 'left' | 'right';
isLast?: boolean;
children: React.ReactNode;
}) {
if (layout === 'left') {
return (
<li
data-slot="timeline-item"
className={cn(
'grid grid-cols-[minmax(5.5rem,auto)_1rem_minmax(0,1fr)] items-start gap-x-4',
className,
)}
{...props}
>
<div
data-slot="timeline-opposite"
className="pt-0.5 text-right tabular-nums"
>
{opposite}
</div>
<div className="relative flex justify-center self-stretch">
{!isLast ? (
<span
aria-hidden
className="absolute top-4 bottom-0 w-px bg-border"
/>
) : null}
<div data-slot="timeline-marker" className="relative z-10">
{marker}
</div>
</div>
<div
data-slot="timeline-content"
className={cn('min-w-0 pb-8', isLast && 'pb-0')}
>
{children}
</div>
</li>
);
}
return (
<li
data-slot="timeline-item"
className={cn(
'grid grid-cols-[2.5rem_minmax(0,1fr)] grid-rows-[auto_auto] gap-x-3 md:grid-cols-[minmax(0,1fr)_2.5rem_minmax(0,1fr)] md:grid-rows-1 md:gap-x-4',
className,
)}
{...props}
>
<div
data-slot="timeline-opposite"
className={cn(
'col-start-2 row-start-1 mb-2 pt-0.5 md:mb-0',
side === 'right'
? 'md:col-start-1 md:text-right'
: 'md:col-start-3 md:text-left',
)}
>
{opposite}
</div>
<div className="relative col-start-1 row-span-2 row-start-1 flex justify-center md:col-start-2 md:row-span-1">
{!isLast ? (
<span
aria-hidden
className="absolute top-4 bottom-0 w-px bg-border"
/>
) : null}
<div data-slot="timeline-marker" className="relative z-10">
{marker}
</div>
</div>
<div
data-slot="timeline-content"
className={cn(
'col-start-2 row-start-2 min-w-0 pb-7 md:row-start-1 md:pb-8',
side === 'right' ? 'md:col-start-3' : 'md:col-start-1',
)}
>
{children}
</div>
</li>
);
}
export { Timeline, TimelineItem };