refactor: video section ui modification

This commit is contained in:
2026-06-23 10:29:09 +05:30
parent 664d80809a
commit a731fca5e0
17 changed files with 668 additions and 378 deletions

View File

@@ -0,0 +1,53 @@
'use client';
import { ArrowLeft, BarChart3 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { TicketDetail } from '@/types';
import { TicketStatusBadge } from '../../components/TicketStatusBadge';
export function TicketDetailHeader({ ticket }: { ticket: TicketDetail }) {
const router = useRouter();
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">
Ticket Detail
</h1>
<TicketStatusBadge status={ticket.status} />
</div>
<p className="text-xs ">Ticket ID: {ticket.id}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{ticket.video_id ? (
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/results/${ticket.video_id}`)}
>
<BarChart3 className="size-4" />
Analysis
</Button>
</PermissionGuard>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => router.push('/ticket')}
>
<ArrowLeft className="size-4" />
Back to List
</Button>
</div>
</div>
);
}

View File

@@ -1,14 +1,13 @@
'use client';
import { History } from 'lucide-react';
import { History, MessageSquareText } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { TicketDetail, TicketHistoryItem, TicketStatus } from '@/types';
import { formatDate } from '@/utils/date';
import { formatTicketStatus } from '../../components/TicketStatusBadge';
import { formatDate } from '@/utils/date';
function getActorLabel(item: TicketHistoryItem) {
return item.actor?.name || item.actor?.email || 'System';
}
@@ -61,7 +60,7 @@ function HistoryEntry({
/>
</span>
<div className="min-w-0 flex-1 space-y-1.5">
<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)}
@@ -74,12 +73,17 @@ function HistoryEntry({
</time>
</div>
<p className="text-sm leading-relaxed text-foreground">
{item.note ||
`${formatTicketStatus(item.to_status)} by ${getActorLabel(item)}.`}
<p className="text-xs font-medium text-muted-foreground">
Updated By{' '}
<span className="text-foreground/90">{getActorLabel(item)}</span>
</p>
<p className="text-xs text-muted-foreground">{getActorLabel(item)}</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>
);

View File

@@ -1,86 +1,138 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { TicketActor, TicketDetail } from '@/types';
import { TicketStatusBadge } from '../../components/TicketStatusBadge';
import { formatDate } from '@/utils/date';
function OverviewMeta({
label,
value,
}: {
label: string;
value: string | number | null | undefined;
}) {
import {
TicketStatusBadge,
} from '../../components/TicketStatusBadge';
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 || '-';
}
function MetaLabel({ children }: { children: React.ReactNode }) {
return (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm font-medium">{value ?? '-'}</p>
<p className="text-xs font-medium tracking-wide text-muted-foreground">
{children}
</p>
);
}
function MetaValue({ children }: { children: React.ReactNode }) {
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 PersonMeta({
function OverviewField({
label,
person,
children,
}: {
label: string;
person: TicketActor | null | undefined;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{label}</p>
{person ? (
<div className="min-w-0">
<p className="truncate text-sm font-medium">{person.name || '-'}</p>
{person.email && (
<p className="truncate text-xs text-muted-foreground">
{person.email}
</p>
)}
</div>
) : (
<p className="text-sm font-medium">-</p>
)}
<MetaLabel>{label}</MetaLabel>
{children}
</div>
);
}
export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<CardTitle className="text-base">Overview</CardTitle>
<TicketStatusBadge status={ticket.status} />
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-5 md:grid-cols-[180px_minmax(0,1fr)]">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">AI Detections</p>
<p className="text-3xl font-semibold leading-none">
{ticket.ai_result?.detection_count ?? '-'}
</p>
<p className="text-xs text-muted-foreground">
Completed {formatDate(ticket.ai_result?.completed_at)}
</p>
</div>
const detectionCount = ticket.ai_result?.detection_count;
<div className="grid gap-4 sm:grid-cols-2">
<PersonMeta label="Uploaded By" person={ticket.uploader} />
<PersonMeta label="Assigned To" person={ticket.worker} />
<PersonMeta
label="Assigned By"
person={ticket.worker?.assigned_by}
/>
<OverviewMeta
label="Assigned At"
value={formatDate(ticket.worker?.assigned_at)}
/>
</div>
return (
<section className="overflow-hidden rounded-xl border bg-card">
<div className="border-b px-4 py-2.5">
<h2 className="text-xs font-medium ">
Overview
</h2>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-6 p-4 md:grid-cols-4">
<OverviewField label="Detection Count">
<MetaValue>
{detectionCount != null ? `${detectionCount} Occurrences` : '-'}
</MetaValue>
</OverviewField>
<OverviewField label="Chainage">
<MetaValue>{ticket.chainage_id || '-'}</MetaValue>
</OverviewField>
<OverviewField label="Uploaded By">
<PersonMetaValue person={ticket.uploader} />
</OverviewField>
<OverviewField label="Created Date">
<MetaValue>{formatCreatedDate(ticket.timestamps.created_at)}</MetaValue>
</OverviewField>
<OverviewField label="Assigned To">
<PersonMetaValue
person={ticket.worker}
accentClassName="bg-secondary text-secondary-foreground"
/>
</OverviewField>
<OverviewField label="Reviewer">
<PersonMetaValue
person={ticket.reviewer}
accentClassName="bg-primary/20 text-primary"
/>
</OverviewField>
<OverviewField label="Assigned At">
<MetaValue>{formatDate(ticket.worker?.assigned_at)}</MetaValue>
</OverviewField>
</div>
{ticket.ai_result?.completed_at ? (
<div className="border-t px-4 py-3 text-xs text-muted-foreground">
AI analysis completed {formatDate(ticket.ai_result.completed_at)}
</div>
</CardContent>
</Card>
) : null}
</section>
);
}

View File

@@ -1,41 +1,33 @@
'use client';
import { ImageIcon } from 'lucide-react';
import type { TicketDetail } from '@/types';
import { RepairNotesCard } from './repair-report/RepairNotesCard';
import { RepairProofImagesGrid } from './repair-report/RepairProofImagesGrid';
import { RepairProofVideoCard } from './repair-report/RepairProofVideoCard';
import { ReviewerCommentsCard } from './repair-report/ReviewerCommentsCard';
import { RepairReportPanel } from './repair-report/RepairReportPanel';
import { RepairVideoComparison } from './repair-report/RepairVideoComparison';
export function TicketRepairReportCard({ ticket }: { ticket: TicketDetail }) {
const repair = ticket.repair;
return (
<section className="space-y-4">
<h2 className="text-base font-semibold text-foreground">
Evidence & Progress
</h2>
<section className="space-y-5">
<RepairReportPanel ticket={ticket} />
<div className="space-y-4">
<RepairProofVideoCard
ticketId={ticket.id}
proofVideoUrl={repair?.proof_video_url ?? null}
hasRepair={Boolean(repair)}
/>
<RepairVideoComparison ticket={ticket} />
<div className="space-y-3">
<h3 className="flex items-center gap-2 text-base font-semibold">
<ImageIcon className="size-5 text-primary" />
Repair Images
</h3>
<RepairProofImagesGrid
imageUrls={repair?.proof_image_urls ?? []}
submittedAt={repair?.submitted_at ?? null}
/>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<RepairNotesCard notes={repair?.notes ?? null} worker={ticket.worker} />
<ReviewerCommentsCard
reviewer={ticket.reviewer}
status={ticket.status}
/>
</div>
</section>
);
}

View File

@@ -6,6 +6,7 @@ import type { TicketDetail } from '@/types';
import type { TicketDetailLiveState } from '../../hooks/useTicketDetailEvents';
import { AssignTicketAction } from './actions/AssignTicketAction';
import { ClosedTicketAction } from './actions/ClosedTicketAction';
import { NoTicketAction } from './actions/NoTicketAction';
import { ProcessingTicketAction } from './actions/ProcessingTicketAction';
import { ReviewRepairAction } from './actions/ReviewRepairAction';
@@ -62,5 +63,9 @@ export function TicketStatusActions({
);
}
if (ticket.status === 'closed') {
return <ClosedTicketAction ticket={ticket} />;
}
return <NoTicketAction status={ticket.status} />;
}

View File

@@ -0,0 +1,155 @@
'use client';
import type { ReactNode } from 'react';
import { Lock, MessageSquare } from 'lucide-react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} 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),
);
}
function getClosedAt(ticket: TicketDetail) {
const closedEntry = ticket.history?.find(
(item) => item.to_status === 'closed',
);
return (
closedEntry?.created_at ??
ticket.reviewer?.reviewed_at ??
ticket.timestamps.updated_at
);
}
function MetaField({
label,
value,
valueClassName,
}: {
label: string;
value: ReactNode;
valueClassName?: string;
}) {
return (
<div className="space-y-1">
<p className="text-xs font-medium tracking-wide text-muted-foreground">
{label}
</p>
<div
className={cn('text-sm font-semibold text-foreground', valueClassName)}
>
{value}
</div>
</div>
);
}
function FinalCommentsPanel({
comment,
}: {
comment: string | null | undefined;
}) {
if (!comment?.trim()) {
return (
<div className="flex min-h-28 flex-col items-center justify-center rounded-lg border border-dashed border-border bg-muted/10 px-4 py-6 text-center">
<MessageSquare className="mb-2 size-5 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No comments added yet</p>
</div>
);
}
return (
<div className="rounded-lg border bg-muted/20 px-4 py-3">
<p className="text-sm leading-relaxed text-foreground">
{comment.trim()}
</p>
</div>
);
}
export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
const reviewer = ticket.reviewer;
const reviewedAt = reviewer?.reviewed_at ?? getClosedAt(ticket);
return (
<Card className="relative overflow-hidden border bg-card/80">
<CardHeader className="relative pb-4">
<CardTitle className="text-lg">Resolution Details</CardTitle>
<CardDescription>Review record for {ticket.id}</CardDescription>
</CardHeader>
<CardContent className="relative space-y-5">
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
<MetaField
label="Reviewed By"
value={
<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
label="Reviewed Date"
value={formatReviewDate(reviewedAt)}
/>
</div>
<div className="space-y-2">
<p className="text-xs font-medium tracking-wide text-muted-foreground">
Review Comment
</p>
<FinalCommentsPanel comment={reviewer?.review_comment} />
</div>
<Button
type="button"
variant="secondary"
className="w-full text-muted-foreground"
disabled
>
<Lock className="size-4" />
Ticket Finalized
</Button>
</CardContent>
</Card>
);
}

View File

@@ -1,13 +1,13 @@
'use client';
import { CircleOff, Clock, Lock } from 'lucide-react';
import { CircleOff, Clock } from 'lucide-react';
import type { TicketStatus } from '@/types';
import { TicketActionCard } from './TicketActionCard';
const statusConfig: Record<
'processing' | 'closed',
'processing',
{ icon: typeof Clock; title: string; message: string }
> = {
processing: {
@@ -15,16 +15,11 @@ const statusConfig: Record<
title: 'Processing',
message: 'This ticket is still being processed. Check back shortly.',
},
closed: {
icon: Lock,
title: 'Ticket Closed',
message: 'This ticket is closed. No further actions are available.',
},
};
export function NoTicketAction({ status }: { status: TicketStatus }) {
const config =
status === 'processing' || status === 'closed'
status === 'processing'
? statusConfig[status]
: {
icon: CircleOff,

View File

@@ -1,45 +0,0 @@
'use client';
import { MessageSquareQuote, ShieldCheck } from 'lucide-react';
import type { TicketDetail } from '@/types';
import {
EmptyPanel,
getPersonLabel,
ReportSurface,
SectionLabel,
} from './repairReportUtils';
export function RepairNotesCard({
notes,
worker,
}: {
notes: string | null;
worker: TicketDetail['worker'];
}) {
return (
<ReportSurface className="flex min-h-48 flex-col gap-4">
<SectionLabel>Repair Notes</SectionLabel>
{notes ? (
<>
<div className="flex-1 rounded-lg border bg-muted/15 px-4 py-3">
<p className="text-sm leading-relaxed text-foreground/90">
{notes}
</p>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<ShieldCheck className="size-3.5 text-primary" />
<span>Self-certified by {getPersonLabel(worker)}</span>
</div>
</>
) : (
<EmptyPanel
icon={MessageSquareQuote}
title="No repair notes added yet"
className="min-h-28 flex-1"
/>
)}
</ReportSurface>
);
}

View File

@@ -11,19 +11,21 @@ import { formatRepairDate, ReportSurface } from './repairReportUtils';
const MAX_MEDIA_SLOTS = 5;
const imageBadgeStyles = [
'bg-slate-700/90 text-white',
'bg-violet-600/90 text-white',
'bg-amber-600/90 text-white',
'bg-stone-600/90 text-white',
'bg-indigo-600/90 text-white',
'bg-primary/90 text-primary-foreground',
'bg-secondary text-secondary-foreground',
'bg-accent text-accent-foreground',
'bg-muted text-muted-foreground',
'bg-primary/70 text-primary-foreground',
] as const;
export function RepairProofImagesGrid({
imageUrls,
submittedAt,
layout = 'row',
}: {
imageUrls: string[];
submittedAt: string | null;
layout?: 'row' | 'sidebar';
}) {
const imageSlots = Array.from(
{ length: MAX_MEDIA_SLOTS },
@@ -31,7 +33,14 @@ export function RepairProofImagesGrid({
);
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<div
className={cn(
'grid gap-3',
layout === 'sidebar'
? 'grid-cols-2 auto-rows-fr'
: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
)}
>
{imageSlots.map((url, index) => (
<RepairProofImageSlot
key={index}
@@ -101,16 +110,8 @@ function RepairProofImageSlot({
<img
src={objectUrl}
alt={`Repair proof ${index + 1}`}
className="aspect-[4/3] w-full object-cover transition-transform group-hover:scale-[1.02]"
className="aspect-4/3 w-full object-cover transition-transform group-hover:scale-[1.02]"
/>
<span
className={cn(
'absolute top-2 left-2 rounded px-1.5 py-0.5 text-[10px] font-semibold tracking-wide uppercase',
imageBadgeStyles[index],
)}
>
Proof {index + 1}
</span>
</a>
<p className="truncate px-1 text-[11px] text-muted-foreground">
{submittedAt ? formatRepairDate(submittedAt) : `Image ${index + 1}`}

View File

@@ -1,138 +0,0 @@
'use client';
import { useRef, useState } from 'react';
import { CircleDashed, Play, Video } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
import { EmptyPanel, ReportSurface } from './repairReportUtils';
interface RepairProofVideoCardProps {
ticketId: string;
proofVideoUrl: string | null;
hasRepair: boolean;
}
export function RepairProofVideoCard({
ticketId,
proofVideoUrl,
hasRepair,
}: RepairProofVideoCardProps) {
if (!proofVideoUrl) {
return (
<ReportSurface className="p-2">
<EmptyPanel
icon={hasRepair ? Video : CircleDashed}
title={
hasRepair ? 'No repair video uploaded' : 'No evidence submitted yet'
}
description={
hasRepair
? 'The assigned worker has not uploaded a repair video yet.'
: 'Repair video will appear here once the assigned worker uploads proof.'
}
className="aspect-[16/9]"
/>
</ReportSurface>
);
}
return (
<ProtectedRepairVideo
url={proofVideoUrl}
label={`Repair Clip #${ticketId.slice(0, 8).toUpperCase()}`}
meta="Repair video - MP4 - Field upload"
/>
);
}
function ProtectedRepairVideo({
url,
label,
meta,
}: {
url: string;
label: string;
meta: string;
}) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url);
if (isLoading) {
return <Skeleton className="aspect-[16/9] w-full rounded-xl border" />;
}
if (isError || !objectUrl) {
return (
<ReportSurface className="p-2">
<EmptyPanel
icon={Video}
title="Video unavailable"
description="The repair video could not be loaded right now."
className="aspect-[16/9]"
/>
</ReportSurface>
);
}
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
void video.play();
setIsPlaying(true);
return;
}
video.pause();
setIsPlaying(false);
};
return (
<div className="group relative overflow-hidden rounded-xl border bg-black/90">
<video
ref={videoRef}
src={objectUrl}
className="aspect-[16/9] w-full object-cover"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
playsInline
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-black/30" />
<span className="absolute top-3 left-3 rounded bg-black/60 px-2.5 py-1 text-[11px] font-semibold text-white backdrop-blur-sm">
{label}
</span>
{!isPlaying ? (
<button
type="button"
onClick={togglePlay}
className="absolute inset-0 flex items-center justify-center"
aria-label="Play video"
>
<span className="flex size-16 items-center justify-center rounded-full border border-white/45 bg-white/20 text-white backdrop-blur-sm transition-transform group-hover:scale-105">
<Play className="ml-1 size-7 fill-current" />
</span>
</button>
) : (
<button
type="button"
onClick={togglePlay}
className="absolute inset-0"
aria-label="Pause video"
/>
)}
<div className="absolute right-0 bottom-0 left-0 flex items-center justify-between gap-3 px-4 py-3 text-xs text-white/85">
<span>{meta}</span>
<Video className="size-4 text-primary" />
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
'use client';
import { FileText, MessageSquareQuote, BadgeCheck } from 'lucide-react';
import type { TicketDetail } from '@/types';
import {
EmptyPanel,
formatRepairDate,
getPersonLabel,
} from './repairReportUtils';
function MetaLabel({ children }: { children: React.ReactNode }) {
return (
<p className="text-xs font-medium tracking-wide text-muted-foreground">
{children}
</p>
);
}
function ReportField({
label,
value,
}: {
label: string;
value: string | null | undefined;
}) {
return (
<div className="space-y-1">
<MetaLabel>{label}</MetaLabel>
<p className="text-sm font-semibold text-foreground">{value || '-'}</p>
</div>
);
}
export function RepairReportPanel({ ticket }: { ticket: TicketDetail }) {
const repair = ticket.repair;
if (!repair) {
return (
<section className="space-y-4 rounded-xl border bg-card p-4 lg:p-6">
<div className="flex items-center gap-2 border-b border-border pb-4">
<FileText className="size-5 text-primary" />
<h3 className="text-lg font-semibold">Repair Report</h3>
</div>
<EmptyPanel
icon={MessageSquareQuote}
title="No repair report submitted yet"
description="Repair details will appear here once the assigned worker submits evidence."
className="py-10"
/>
</section>
);
}
return (
<section className="space-y-4 rounded-xl border bg-card p-4 lg:p-6">
<div className="flex flex-col gap-2 border-b border-border pb-4 sm:flex-row sm:items-center sm:justify-between">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<FileText className="size-5 text-primary" />
Repair Report
</h3>
{repair.submitted_at ? (
<span className="text-xs text-muted-foreground italic">
Completed {formatRepairDate(repair.submitted_at)}
</span>
) : null}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ReportField label="Technician" value={getPersonLabel(ticket.worker)} />
<ReportField
label="Submitted At"
value={
repair.submitted_at ? formatRepairDate(repair.submitted_at) : null
}
/>
</div>
<div className="space-y-2 pt-1">
<MetaLabel>Repair Summary</MetaLabel>
{repair.notes?.trim() ? (
<>
<div className="rounded-lg border bg-muted/15 px-4 py-3">
<p className="text-sm leading-relaxed text-foreground/90">
{repair.notes}
</p>
</div>
{/* <div className="flex items-center gap-2 text-xs text-muted-foreground">
<BadgeCheck className="size-3.5 text-primary" />
<span>Self-certified by {getPersonLabel(ticket.worker)}</span>
</div> */}
</>
) : (
<EmptyPanel
icon={MessageSquareQuote}
title="No repair notes added yet"
className="py-6"
/>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
import { ShieldCheck } from 'lucide-react';
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
import type { TicketDetail } from '@/types';
export function RepairVideoComparison({ ticket }: { ticket: TicketDetail }) {
const originalVideoUrl = ticket.video?.url ?? null;
const repairVideoUrl = ticket.repair?.proof_video_url ?? null;
return (
<section className="space-y-4">
<h3 className="flex items-center gap-2 text-base font-semibold">
<ShieldCheck className="size-5 text-primary" />
Repair Verification
</h3>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<ComparisonVideoItem
tooltip={ticket.video?.name ?? 'Original inspection video'}
videoLabel="Before"
videoUrl={originalVideoUrl}
meta="AI detection video"
emptyTitle="No original video available"
emptyDescription="The source inspection video is not attached to this ticket."
/>
<ComparisonVideoItem
tooltip="Worker repair evidence video"
videoLabel="After"
videoUrl={repairVideoUrl}
meta="Worker evidence video"
emptyTitle={
ticket.repair
? 'No repair video uploaded'
: 'No evidence submitted yet'
}
emptyDescription={
ticket.repair
? 'The assigned worker has not uploaded a repair video yet.'
: 'Repair video will appear here once evidence is submitted.'
}
/>
</div>
</section>
);
}
function ComparisonVideoItem({
tooltip,
videoLabel,
videoUrl,
meta,
emptyTitle,
emptyDescription,
}: {
tooltip: string;
videoLabel: string;
videoUrl: string | null;
meta: string;
emptyTitle: string;
emptyDescription: string;
}) {
return (
<div title={tooltip}>
<ProtectedVideoPlayer
url={videoUrl}
label={videoLabel}
meta={meta}
emptyTitle={emptyTitle}
emptyDescription={emptyDescription}
/>
</div>
);
}

View File

@@ -1,63 +0,0 @@
'use client';
import { Clock3 } from 'lucide-react';
import type { TicketDetail } from '@/types';
import {
EmptyPanel,
formatRepairDate,
getPersonLabel,
ReportSurface,
SectionLabel,
} from './repairReportUtils';
export function ReviewerCommentsCard({
reviewer,
status,
}: {
reviewer: TicketDetail['reviewer'];
status: TicketDetail['status'];
}) {
const hasReview = Boolean(reviewer?.review_comment || reviewer?.reviewed_at);
return (
<ReportSurface className="flex min-h-48 flex-col gap-4">
<SectionLabel>Reviewer Comments</SectionLabel>
{hasReview ? (
<>
<div className="flex-1 rounded-lg border bg-muted/15 px-4 py-3">
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{getPersonLabel(reviewer)}
</p>
{reviewer?.email ? (
<p className="truncate text-xs text-muted-foreground">
{reviewer.email}
</p>
) : null}
</div>
<time className="shrink-0 text-[11px] text-muted-foreground tabular-nums">
{formatRepairDate(reviewer?.reviewed_at)}
</time>
</div>
<p className="text-sm leading-relaxed text-foreground/90">
{reviewer?.review_comment || 'No comment provided.'}
</p>
</div>
<div className="flex items-center gap-2 text-[11px] font-semibold tracking-[0.12em] text-amber-500 uppercase">
<span className="size-2 rounded-sm bg-amber-500" />
{status === 'closed' ? 'Repair approved' : 'Review completed'}
</div>
</>
) : (
<EmptyPanel
icon={Clock3}
title="No comments added yet"
className="min-h-28 flex-1"
/>
)}
</ReportSurface>
);
}

View File

@@ -1,11 +1,12 @@
'use client';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, Loader2, Ticket } from 'lucide-react';
import { ArrowLeft, Loader2 } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { TicketDetailHeader } from './components/TicketDetailHeader';
import { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketOverviewCard } from './components/TicketOverviewCard';
import { TicketRepairReportCard } from './components/TicketRepairReportCard';
@@ -51,23 +52,9 @@ export default function TicketDetailPage() {
return (
<main className="relative z-10 space-y-5">
<PageHeader
title="Ticket Detail"
description={`Ticket ID: ${ticket.id}`}
icon={Ticket}
actions={
<Button
variant="outline"
size="sm"
onClick={() => router.push('/ticket')}
>
<ArrowLeft />
Back
</Button>
}
/>
<TicketDetailHeader ticket={ticket} />
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_480px]">
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]">
<div className="space-y-5">
<TicketOverviewCard ticket={ticket} />
<TicketRepairReportCard ticket={ticket} />

View File

@@ -10,6 +10,7 @@ const options = [
{ value: 'yoloe_trained_vl', label: 'YOLOE With Vision Language Model' },
{ value: 'culvert_detection', label: 'Culvert Detection' },
{ value: 'combined', label: 'Road Defect Detection with vl' },
{ value: 'yoloe_seg', label: 'Improved Road Defect Detection' },
] as const;
interface UploadSettingsSectionProps {