diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketClassDetailSkeleton.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketClassDetailSkeleton.tsx index 65bc990..7a45a30 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/TicketClassDetailSkeleton.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/TicketClassDetailSkeleton.tsx @@ -54,29 +54,28 @@ export function TicketClassContentSkeleton() { - - +
+
+ - - - {Array.from({ length: 3 }).map((_, index) => ( -
- {index < 2 ? ( - - ) : null} - -
-
- - -
- - +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ {index < 2 ? ( + + ) : null} + +
+
+ +
+ +
- ))} - - +
+ ))} +
); } diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketHistoryCard.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketHistoryCard.tsx index d5e70dc..1c56452 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/TicketHistoryCard.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/TicketHistoryCard.tsx @@ -2,87 +2,76 @@ 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 type { TicketDetail, TicketHistoryItem, TicketStatus } from '@/types'; -import { formatDate } from '@/utils/date'; +import type { TicketDetail, TicketHistoryItem } from '@/types'; +import { formatTimelineDateTime } from '@/utils/date'; +import { getPersonLabel } from '@/utils/person'; -function getActorLabel(item: TicketHistoryItem) { - return item.actor?.name || item.actor?.email || 'System'; -} - -function getLifecycleTitle(status: TicketStatus) { - const titles: Record = { - 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'; +function TimelineDate({ value }: { value: string }) { + const { date, time } = formatTimelineDateTime(value); return ( -
- {!isLast ? ( - - ) : null} + + ); +} - - - +function HistoryNote({ item }: { item: TicketHistoryItem }) { + const noteText = item.note?.text?.trim(); + if (!noteText) return null; -
-
-

- {getLifecycleTitle(item.to_status)} -

- -
- -

- Updated By{' '} - {getActorLabel(item)} -

- - {item.note ? ( -
- -

{item.note}

-
- ) : null} + return ( +
+
+ + + Note by {getPersonLabel(item.note?.author ?? item.actor)} +
+

+ {noteText} +

+
+ ); +} + +function SystemHistoryEntry({ item }: { item: TicketHistoryItem }) { + const noteText = item.note?.text?.trim(); + + return ( +
+ + System Generated + +

{item.title}

+ {noteText ? ( +

+ {noteText} +

+ ) : null} +
+ ); +} + +function UserHistoryEntry({ item }: { item: TicketHistoryItem }) { + const noteText = item.note?.text?.trim(); + const isAssigned = item.event_type === 'assigned'; + + return ( +
+

{item.title}

+ + {isAssigned && item.target_user ? ( +
+

Assigned to

+ +
+ ) : null} + {noteText ? : null}
); } @@ -91,33 +80,48 @@ export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) { const history = ticket.history ?? []; return ( - - - - - Audit & Lifecycle Timeline - - +
+
+ +

Audit & Lifecycle Timeline

+
- - {history.length ? ( -
- {history.map((item, index) => ( - - ))} -
- ) : ( -
-

- No timeline entries yet -

-
- )} -
- + {history.length ? ( + + {history.map((item, index) => ( + } + marker={ + + + + } + > + {item.source === 'system' ? ( + + ) : ( + + )} + + ))} + + ) : ( +
+

+ No timeline entries yet +

+
+ )} +
); } diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketOverviewCard.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketOverviewCard.tsx index 9176db2..37ba579 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/TicketOverviewCard.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/TicketOverviewCard.tsx @@ -1,28 +1,9 @@ 'use client'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import type { TicketActor, TicketOverviewDetail } from '@/types'; - -function formatCreatedDate(value: string | null | undefined) { - 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 || '-'; -} +import { PersonInfo } from '@/components/person-avatar'; +import type { TicketOverviewDetail } from '@/types'; +import { formatDateOnly } from '@/utils/date'; function MetaLabel({ children }: { children: React.ReactNode }) { return ( @@ -36,29 +17,6 @@ function MetaValue({ children }: { children: React.ReactNode }) { return

{children}

; } -function PersonMetaValue({ - person, - accentClassName = 'bg-primary/15 text-primary', -}: { - person: TicketActor | null | undefined; - accentClassName?: string; -}) { - if (!person) { - return -; - } - - return ( -
- - {getInitials(person)} - -

{getPersonLabel(person)}

-
- ); -} - function OverviewField({ label, children, @@ -95,12 +53,12 @@ export function TicketOverviewCard({ - + - {formatCreatedDate(ticket.timestamps.created_at)} + {formatDateOnly(ticket.timestamps.created_at)} diff --git a/src/app/(modules)/ticket/[ticketId]/components/actions/ClosedTicketAction.tsx b/src/app/(modules)/ticket/[ticketId]/components/actions/ClosedTicketAction.tsx index e8aad22..d808eba 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/actions/ClosedTicketAction.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/actions/ClosedTicketAction.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from '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 { Card, @@ -14,34 +14,18 @@ import { } from '@/components/ui/card'; import { cn } from '@/lib/utils'; import type { TicketDetail } from '@/types'; - -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), - ); -} +import { formatDateOnly } from '@/utils/date'; function getClosedAt(ticket: TicketDetail) { - const closedEntry = ticket.history?.find( - (item) => item.to_status === 'closed', - ); + const approvedEntry = [...(ticket.history ?? [])] + .reverse() + .find( + (item) => + item.event_type === 'repair_approved' || + item.to_class_status === 'approved', + ); return ( - closedEntry?.created_at ?? + approvedEntry?.created_at ?? ticket.reviewer?.reviewed_at ?? ticket.timestamps.updated_at ); @@ -101,35 +85,19 @@ export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) { Resolution Details - Review record for {ticket.id} + Review record for {ticket.ticket_name}
- - - {getInitials(reviewer?.name, reviewer?.email)} - - -
-

{getPersonLabel(reviewer)}

- {reviewer?.email ? ( -

- {reviewer.email} -

- ) : null} -
-
- } + value={} />
diff --git a/src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairProofImagesGrid.tsx b/src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairProofImagesGrid.tsx index 71959b0..a084334 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairProofImagesGrid.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairProofImagesGrid.tsx @@ -5,8 +5,9 @@ import { ImageIcon } from 'lucide-react'; import { Skeleton } from '@/components/ui/skeleton'; import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia'; import { cn } from '@/lib/utils'; +import { formatDate } from '@/utils/date'; -import { formatRepairDate, ReportSurface } from './repairReportUtils'; +import { ReportSurface } from './repairReportUtils'; const MAX_MEDIA_SLOTS = 5; @@ -114,7 +115,7 @@ function RepairProofImageSlot({ />

- {submittedAt ? formatRepairDate(submittedAt) : `Image ${index + 1}`} + {submittedAt ? formatDate(submittedAt) : `Image ${index + 1}`}

); diff --git a/src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairReportPanel.tsx b/src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairReportPanel.tsx index 2f8f70e..a69fcd1 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairReportPanel.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairReportPanel.tsx @@ -2,14 +2,12 @@ import { FileText, MessageSquareQuote } from 'lucide-react'; +import { PersonInfo } from '@/components/person-avatar'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import type { TicketDetail } from '@/types'; +import { formatDate } from '@/utils/date'; -import { - EmptyPanel, - formatRepairDate, - getPersonLabel, -} from './repairReportUtils'; +import { EmptyPanel } from './repairReportUtils'; function MetaLabel({ children }: { children: React.ReactNode }) { return ( @@ -67,21 +65,21 @@ export function RepairReportPanel({ ticket }: { ticket: TicketDetail }) { {repair.submitted_at ? ( - Completed {formatRepairDate(repair.submitted_at)} + Completed {formatDate(repair.submitted_at)} ) : null}
- +
+ Technician + +
diff --git a/src/app/(modules)/ticket/[ticketId]/components/repair-report/repairReportUtils.tsx b/src/app/(modules)/ticket/[ticketId]/components/repair-report/repairReportUtils.tsx index 0108762..93b443a 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/repair-report/repairReportUtils.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/repair-report/repairReportUtils.tsx @@ -3,19 +3,6 @@ import type { LucideIcon } from 'lucide-react'; 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 }) { return ( diff --git a/src/components/person-avatar.tsx b/src/components/person-avatar.tsx new file mode 100644 index 0000000..5addfe4 --- /dev/null +++ b/src/components/person-avatar.tsx @@ -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 ( + + {avatarUrl ? ( + + ) : null} + + + + + ); +} + +type PersonInfoProps = { + person: TicketActor | null | undefined; + size?: 'default' | 'sm' | 'lg'; + className?: string; +}; + +export function PersonInfo({ + person, + size = 'sm', + className, +}: PersonInfoProps) { + if (!person) { + return

-

; + } + + const name = person.name?.trim(); + const email = person.email?.trim(); + + return ( +
+ +
+

+ {name || email || 'Unknown'} +

+ {name && email ? ( +

{email}

+ ) : null} +
+
+ ); +} diff --git a/src/components/ui/timeline.tsx b/src/components/ui/timeline.tsx new file mode 100644 index 0000000..7fd021a --- /dev/null +++ b/src/components/ui/timeline.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Timeline({ className, ...props }: React.ComponentProps<'ol'>) { + return ( +
    + ); +} + +function TimelineItem({ + className, + marker, + opposite, + layout = 'alternate', + side = 'right', + isLast = false, + children, + ...props +}: Omit, 'children'> & { + marker: React.ReactNode; + opposite: React.ReactNode; + layout?: 'left' | 'alternate'; + side?: 'left' | 'right'; + isLast?: boolean; + children: React.ReactNode; +}) { + if (layout === 'left') { + return ( +
  1. +
    + {opposite} +
    + +
    + {!isLast ? ( + + ) : null} +
    + {marker} +
    +
    + +
    + {children} +
    +
  2. + ); + } + + return ( +
  3. +
    + {opposite} +
    +
    + {!isLast ? ( + + ) : null} +
    + {marker} +
    +
    +
    + {children} +
    +
  4. + ); +} + +export { Timeline, TimelineItem }; diff --git a/src/types/ticket/detail.ts b/src/types/ticket/detail.ts index c205f13..be2430f 100644 --- a/src/types/ticket/detail.ts +++ b/src/types/ticket/detail.ts @@ -5,14 +5,29 @@ export interface TicketActor { user_id: number | null; name: 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 { id: number; - from_status: TicketStatus | null; - to_status: TicketStatus; + event_type: string; + 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; - note: string | null; + target_user: TicketActor | null; + note: TicketHistoryNote | null; created_at: string; } diff --git a/src/utils/date.ts b/src/utils/date.ts index 11bfffa..e897bbd 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -4,4 +4,25 @@ export function formatDate(value: string | null | undefined) { dateStyle: 'medium', timeStyle: 'short', }).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, + ), + }; } \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts index 98adf9e..f229781 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './routes'; -export * from './date'; \ No newline at end of file +export * from './date'; +export * from './person'; \ No newline at end of file diff --git a/src/utils/person.ts b/src/utils/person.ts new file mode 100644 index 0000000..ed0b065 --- /dev/null +++ b/src/utils/person.ts @@ -0,0 +1,8 @@ +import type { TicketActor } from '@/types'; + +export function getPersonLabel( + person: TicketActor | null | undefined, + fallback = 'Unknown', +) { + return person?.name || person?.email || fallback; +}