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

@@ -54,29 +54,28 @@ export function TicketClassContentSkeleton() {
</CardContent> </CardContent>
</Card> </Card>
<Card> <div className="space-y-4">
<CardHeader> <div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-5 w-56" /> <Skeleton className="h-5 w-56" />
</CardHeader> </div>
<CardContent className="pl-5.5"> {Array.from({ length: 3 }).map((_, index) => (
{Array.from({ length: 3 }).map((_, index) => ( <div key={index} className="relative flex gap-4 pb-8 last:pb-0">
<div key={index} className="relative flex gap-4 pb-8 last:pb-0"> {index < 2 ? (
{index < 2 ? ( <span className="absolute top-6 left-2.75 h-[calc(100%-10px)] w-px bg-border" />
<span className="absolute top-6 left-2.75 h-[calc(100%-10px)] w-px bg-border" /> ) : null}
) : null} <Skeleton className="relative z-10 mt-0.5 size-6 shrink-0 rounded-full" />
<Skeleton className="relative z-10 mt-0.5 size-6 shrink-0 rounded-full" /> <div className="min-w-0 flex-1 space-y-3">
<div className="min-w-0 flex-1 space-y-3"> <div className="flex flex-wrap items-center gap-3">
<div className="flex flex-wrap items-center gap-3"> <Skeleton className="h-4 w-44" />
<Skeleton className="h-4 w-44" /> <Skeleton className="h-3 w-28" />
<Skeleton className="h-3 w-28" />
</div>
<Skeleton className="h-3 w-56" />
<Skeleton className="h-12 w-full" />
</div> </div>
<Skeleton className="h-3 w-56" />
<Skeleton className="h-12 w-full" />
</div> </div>
))} </div>
</CardContent> ))}
</Card> </div>
</div> </div>
); );
} }

View File

@@ -2,87 +2,76 @@
import { History, MessageSquareText } from 'lucide-react'; 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 { cn } from '@/lib/utils';
import type { TicketDetail, TicketHistoryItem, TicketStatus } from '@/types'; import type { TicketDetail, TicketHistoryItem } from '@/types';
import { formatDate } from '@/utils/date'; import { formatTimelineDateTime } from '@/utils/date';
import { getPersonLabel } from '@/utils/person';
function getActorLabel(item: TicketHistoryItem) { function TimelineDate({ value }: { value: string }) {
return item.actor?.name || item.actor?.email || 'System'; const { date, time } = formatTimelineDateTime(value);
}
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';
return ( return (
<div className="relative flex gap-4 pb-8 last:pb-0"> <time dateTime={value} className="block tabular-nums">
{!isLast ? ( <span className="block text-sm font-semibold text-foreground">{date}</span>
<span <span className="block text-xs text-muted-foreground">{time}</span>
aria-hidden </time>
className="absolute top-6 left-2.75 h-[calc(100%-10px)] w-px bg-border" );
/> }
) : null}
<span function HistoryNote({ item }: { item: TicketHistoryItem }) {
aria-hidden const noteText = item.note?.text?.trim();
className={cn( if (!noteText) return null;
'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>
<div className="min-w-0 flex-1 space-y-2"> return (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1"> <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">
<h3 className="text-sm font-semibold text-foreground"> <div className="flex items-center gap-1.5 text-xs font-medium text-amber-900 dark:text-amber-200">
{getLifecycleTitle(item.to_status)} <MessageSquareText className="size-3.5 shrink-0 text-amber-700 dark:text-amber-300" />
</h3> <span>
<time Note by {getPersonLabel(item.note?.author ?? item.actor)}
dateTime={item.created_at} </span>
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}
</div> </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> </div>
); );
} }
@@ -91,33 +80,48 @@ export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
const history = ticket.history ?? []; const history = ticket.history ?? [];
return ( return (
<Card> <div className="space-y-4">
<CardHeader> <div className="flex items-center gap-2">
<CardTitle className="flex items-center gap-2"> <History className="size-5 text-primary" />
<History className="size-5 text-primary" /> <h2 className="text-base font-semibold">Audit & Lifecycle Timeline</h2>
Audit & Lifecycle Timeline </div>
</CardTitle>
</CardHeader>
<CardContent> {history.length ? (
{history.length ? ( <Timeline>
<div className="pl-0.5"> {history.map((item, index) => (
{history.map((item, index) => ( <TimelineItem
<HistoryEntry key={item.id}
key={item.id} layout="left"
item={item} isLast={index === history.length - 1}
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">
</div> <span
) : ( className={cn(
<div className="rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center"> 'size-1.5 rounded-full',
<p className="text-sm font-medium text-muted-foreground"> item.source === 'system'
No timeline entries yet ? 'bg-muted-foreground'
</p> : 'bg-primary',
</div> )}
)} />
</CardContent> </span>
</Card> }
>
{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>
); );
} }

View File

@@ -1,28 +1,9 @@
'use client'; 'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { TicketActor, TicketOverviewDetail } from '@/types'; import { PersonInfo } from '@/components/person-avatar';
import type { TicketOverviewDetail } from '@/types';
function formatCreatedDate(value: string | null | undefined) { import { formatDateOnly } from '@/utils/date';
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium' }).format(
new Date(value),
);
}
function getInitials(person: TicketActor | null | undefined) {
const source = person?.name || person?.email || '?';
return source
.split(/[\s@._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('');
}
function getPersonLabel(person: TicketActor | null | undefined) {
return person?.name || person?.email || '-';
}
function MetaLabel({ children }: { children: React.ReactNode }) { function MetaLabel({ children }: { children: React.ReactNode }) {
return ( return (
@@ -36,29 +17,6 @@ function MetaValue({ children }: { children: React.ReactNode }) {
return <p className="text-sm font-semibold text-foreground">{children}</p>; return <p className="text-sm font-semibold text-foreground">{children}</p>;
} }
function PersonMetaValue({
person,
accentClassName = 'bg-primary/15 text-primary',
}: {
person: TicketActor | null | undefined;
accentClassName?: string;
}) {
if (!person) {
return <MetaValue>-</MetaValue>;
}
return (
<div className="flex min-w-0 items-center gap-2">
<span
className={`flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold ${accentClassName}`}
>
{getInitials(person)}
</span>
<p className="truncate text-sm font-medium">{getPersonLabel(person)}</p>
</div>
);
}
function OverviewField({ function OverviewField({
label, label,
children, children,
@@ -95,12 +53,12 @@ export function TicketOverviewCard({
</OverviewField> </OverviewField>
<OverviewField label="Uploaded By"> <OverviewField label="Uploaded By">
<PersonMetaValue person={ticket.uploader} /> <PersonInfo person={ticket.uploader} />
</OverviewField> </OverviewField>
<OverviewField label="Created Date"> <OverviewField label="Created Date">
<MetaValue> <MetaValue>
{formatCreatedDate(ticket.timestamps.created_at)} {formatDateOnly(ticket.timestamps.created_at)}
</MetaValue> </MetaValue>
</OverviewField> </OverviewField>
</CardContent> </CardContent>

View File

@@ -3,7 +3,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Lock, MessageSquare } from 'lucide-react'; import { Lock, MessageSquare } from 'lucide-react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { PersonInfo } from '@/components/person-avatar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Card, Card,
@@ -14,34 +14,18 @@ import {
} from '@/components/ui/card'; } from '@/components/ui/card';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { TicketDetail } from '@/types'; import type { TicketDetail } from '@/types';
import { formatDateOnly } from '@/utils/date';
function getInitials(name?: string | null, email?: string | null) {
const source = name || email || '?';
return source
.split(/[\s@._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('');
}
function getPersonLabel(person: TicketDetail['reviewer']) {
return person?.name || person?.email || 'Unknown';
}
function formatReviewDate(value: string | null | undefined) {
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium' }).format(
new Date(value),
);
}
function getClosedAt(ticket: TicketDetail) { function getClosedAt(ticket: TicketDetail) {
const closedEntry = ticket.history?.find( const approvedEntry = [...(ticket.history ?? [])]
(item) => item.to_status === 'closed', .reverse()
); .find(
(item) =>
item.event_type === 'repair_approved' ||
item.to_class_status === 'approved',
);
return ( return (
closedEntry?.created_at ?? approvedEntry?.created_at ??
ticket.reviewer?.reviewed_at ?? ticket.reviewer?.reviewed_at ??
ticket.timestamps.updated_at ticket.timestamps.updated_at
); );
@@ -101,35 +85,19 @@ export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Resolution Details</CardTitle> <CardTitle className="text-lg">Resolution Details</CardTitle>
<CardDescription>Review record for {ticket.id}</CardDescription> <CardDescription>Review record for {ticket.ticket_name}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
<div className="space-y-4 rounded-xl border bg-muted/20 p-4"> <div className="space-y-4 rounded-xl border bg-muted/20 p-4">
<MetaField <MetaField
label="Reviewed By" label="Reviewed By"
value={ value={<PersonInfo person={reviewer} size="lg" />}
<div className="flex min-w-0 items-center gap-3">
<Avatar className="size-10 border border-primary/30">
<AvatarFallback className="bg-primary/15 text-sm text-primary">
{getInitials(reviewer?.name, reviewer?.email)}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate">{getPersonLabel(reviewer)}</p>
{reviewer?.email ? (
<p className="mt-0.5 truncate text-xs font-medium text-muted-foreground">
{reviewer.email}
</p>
) : null}
</div>
</div>
}
/> />
<MetaField <MetaField
label="Reviewed Date" label="Reviewed Date"
value={formatReviewDate(reviewedAt)} value={formatDateOnly(reviewedAt)}
/> />
</div> </div>

View File

@@ -5,8 +5,9 @@ import { ImageIcon } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia'; import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatDate } from '@/utils/date';
import { formatRepairDate, ReportSurface } from './repairReportUtils'; import { ReportSurface } from './repairReportUtils';
const MAX_MEDIA_SLOTS = 5; const MAX_MEDIA_SLOTS = 5;
@@ -114,7 +115,7 @@ function RepairProofImageSlot({
/> />
</a> </a>
<p className="truncate px-1 text-[11px] text-muted-foreground"> <p className="truncate px-1 text-[11px] text-muted-foreground">
{submittedAt ? formatRepairDate(submittedAt) : `Image ${index + 1}`} {submittedAt ? formatDate(submittedAt) : `Image ${index + 1}`}
</p> </p>
</ReportSurface> </ReportSurface>
); );

View File

@@ -2,14 +2,12 @@
import { FileText, MessageSquareQuote } from 'lucide-react'; import { FileText, MessageSquareQuote } from 'lucide-react';
import { PersonInfo } from '@/components/person-avatar';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { TicketDetail } from '@/types'; import type { TicketDetail } from '@/types';
import { formatDate } from '@/utils/date';
import { import { EmptyPanel } from './repairReportUtils';
EmptyPanel,
formatRepairDate,
getPersonLabel,
} from './repairReportUtils';
function MetaLabel({ children }: { children: React.ReactNode }) { function MetaLabel({ children }: { children: React.ReactNode }) {
return ( return (
@@ -67,21 +65,21 @@ export function RepairReportPanel({ ticket }: { ticket: TicketDetail }) {
</CardTitle> </CardTitle>
{repair.submitted_at ? ( {repair.submitted_at ? (
<span className="text-xs text-muted-foreground italic sm:col-start-2 sm:row-start-1"> <span className="text-xs text-muted-foreground italic sm:col-start-2 sm:row-start-1">
Completed {formatRepairDate(repair.submitted_at)} Completed {formatDate(repair.submitted_at)}
</span> </span>
) : null} ) : null}
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ReportField <div className="space-y-1">
label="Technician" <MetaLabel>Technician</MetaLabel>
value={getPersonLabel(ticket.worker)} <PersonInfo person={ticket.worker} />
/> </div>
<ReportField <ReportField
label="Submitted At" label="Submitted At"
value={ value={
repair.submitted_at ? formatRepairDate(repair.submitted_at) : null repair.submitted_at ? formatDate(repair.submitted_at) : null
} }
/> />
</div> </div>

View File

@@ -3,19 +3,6 @@
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { TicketActor } from '@/types';
export function formatRepairDate(value: string | null | undefined) {
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
export function getPersonLabel(person: TicketActor | null | undefined) {
return person?.name || person?.email || 'Unknown';
}
export function SectionLabel({ children }: { children: React.ReactNode }) { export function SectionLabel({ children }: { children: React.ReactNode }) {
return ( return (

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 };

View File

@@ -5,14 +5,29 @@ export interface TicketActor {
user_id: number | null; user_id: number | null;
name: string | null; name: string | null;
email: string | null; email: string | null;
avatar_url?: string | null;
}
export type TicketHistorySource = 'system' | 'user';
export interface TicketHistoryNote {
kind: string;
author: TicketActor | null;
text: string;
} }
export interface TicketHistoryItem { export interface TicketHistoryItem {
id: number; id: number;
from_status: TicketStatus | null; event_type: string;
to_status: TicketStatus; title: string;
source: TicketHistorySource;
assignment_id: number | null;
defect_class: string | null;
from_class_status: TicketDefectClassTicketStatus | null;
to_class_status: TicketDefectClassTicketStatus | null;
actor: TicketActor | null; actor: TicketActor | null;
note: string | null; target_user: TicketActor | null;
note: TicketHistoryNote | null;
created_at: string; created_at: string;
} }

View File

@@ -5,3 +5,24 @@ export function formatDate(value: string | null | undefined) {
timeStyle: 'short', timeStyle: 'short',
}).format(new Date(value)); }).format(new Date(value));
} }
export function formatDateOnly(value: string | null | undefined) {
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium' }).format(
new Date(value),
);
}
export function formatTimelineDateTime(value: string | null | undefined) {
if (!value) return { date: '-', time: '-' };
const parsed = new Date(value);
return {
date: new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium' }).format(
parsed,
),
time: new Intl.DateTimeFormat('en-IN', { timeStyle: 'short' }).format(
parsed,
),
};
}

View File

@@ -1,2 +1,3 @@
export * from './routes'; export * from './routes';
export * from './date'; export * from './date';
export * from './person';

8
src/utils/person.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { TicketActor } from '@/types';
export function getPersonLabel(
person: TicketActor | null | undefined,
fallback = 'Unknown',
) {
return person?.name || person?.email || fallback;
}