feat(ticket): refine repair report and history timeline UI

This commit is contained in:
2026-06-19 11:22:41 +05:30
parent 8c3d19a429
commit a567a52e26
27 changed files with 1550 additions and 363 deletions

View File

@@ -0,0 +1,125 @@
'use client';
import { History } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { TicketDetail, TicketHistoryItem, TicketStatus } from '@/types';
import { formatTicketStatus } from '../../components/TicketStatusBadge';
function formatDate(value: string | null | undefined) {
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function getActorLabel(item: TicketHistoryItem) {
return item.actor?.name || item.actor?.email || 'System';
}
function getLifecycleTitle(status: TicketStatus) {
const titles: Record<TicketStatus, string> = {
processing: 'Incident Ticket Created',
unassigned: 'Awaiting Assignment',
assigned: 'Dispatched to Maintenance',
under_review: 'Maintenance Finalized',
closed: 'Ticket Closed',
};
return titles[status] ?? formatTicketStatus(status);
}
function HistoryEntry({
item,
isLast,
}: {
item: TicketHistoryItem;
isLast: boolean;
}) {
const isClosed = item.to_status === 'closed';
return (
<div className="relative flex gap-4 pb-8 last:pb-0">
{!isLast ? (
<span
aria-hidden
className="absolute top-6 left-[11px] h-[calc(100%-10px)] w-px bg-border"
/>
) : null}
<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-[-45deg] rounded-none border-b-2 border-l-2 border-current bg-transparent',
)}
/>
</span>
<div className="min-w-0 flex-1 space-y-1.5">
<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-sm leading-relaxed text-foreground">
{item.note ||
`${formatTicketStatus(item.to_status)} by ${getActorLabel(item)}.`}
</p>
<p className="text-xs text-muted-foreground">{getActorLabel(item)}</p>
</div>
</div>
);
}
export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
const history = ticket.history ?? [];
return (
<section className="rounded-xl border bg-card p-5">
<div className="mb-5 flex items-center gap-2">
<History className="size-5 text-primary" />
<h2 className="text-base font-semibold text-foreground">
Audit & Lifecycle Timeline
</h2>
</div>
{history.length ? (
<div className={cn('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>
)}
</section>
);
}