Compare commits
2 Commits
b1174c8825
...
c73ba0a01f
| Author | SHA1 | Date | |
|---|---|---|---|
| c73ba0a01f | |||
| 1a94ff80e6 |
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const ModulesLayout = ({
|
|||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden">
|
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden">
|
||||||
<div className="flex-1 overflow-auto [scrollbar-gutter:stable]">
|
<div className="scroll-stable flex-1 overflow-auto">
|
||||||
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
|
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
|
||||||
<div className="flex gap-3 items-center sticky top-0 bg-background z-50 py-3 px-6 border-b border-border">
|
<div className="flex gap-3 items-center sticky top-0 bg-background z-50 py-3 px-6 border-b border-border">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ 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 ? (
|
||||||
@@ -75,8 +75,7 @@ export function TicketClassContentSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
className="absolute top-6 left-2.75 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-45 rounded-none border-b-2 border-l-2 border-current bg-transparent',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
|
||||||
<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>
|
</time>
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
function HistoryNote({ item }: { item: TicketHistoryItem }) {
|
||||||
Updated By{' '}
|
const noteText = item.note?.text?.trim();
|
||||||
<span className="text-foreground/90">{getActorLabel(item)}</span>
|
if (!noteText) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<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)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-sm leading-relaxed text-amber-950/80 dark:text-amber-100/90">
|
||||||
|
{noteText}
|
||||||
</p>
|
</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>
|
</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}
|
) : null}
|
||||||
</div>
|
</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,25 +80,41 @@ 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" />
|
||||||
Audit & Lifecycle Timeline
|
<h2 className="text-base font-semibold">Audit & Lifecycle Timeline</h2>
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
{history.length ? (
|
|
||||||
<div className="pl-0.5">
|
|
||||||
{history.map((item, index) => (
|
|
||||||
<HistoryEntry
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
isLast={index === history.length - 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{history.length ? (
|
||||||
|
<Timeline>
|
||||||
|
{history.map((item, index) => (
|
||||||
|
<TimelineItem
|
||||||
|
key={item.id}
|
||||||
|
layout="left"
|
||||||
|
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">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'size-1.5 rounded-full',
|
||||||
|
item.source === 'system'
|
||||||
|
? 'bg-muted-foreground'
|
||||||
|
: 'bg-primary',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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">
|
<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">
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
@@ -117,7 +122,6 @@ export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -122,16 +122,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
|
||||||
|
|
||||||
html:has(body[data-scroll-locked]) {
|
html:has(body[data-scroll-locked]) {
|
||||||
scrollbar-gutter: auto;
|
scrollbar-gutter: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports not (scrollbar-gutter: stable) {
|
@supports not (scrollbar-gutter: stable) {
|
||||||
html {
|
.scroll-stable {
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,4 +139,8 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scroll-stable {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
src/components/person-avatar.tsx
Normal file
72
src/components/person-avatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/components/ui/timeline.tsx
Normal file
115
src/components/ui/timeline.tsx
Normal 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 };
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
8
src/utils/person.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user