refactor: video section ui modification
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user