refactor: video section ui modification
This commit is contained in:
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.
|
||||||
|
|||||||
@@ -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';
|
'use client';
|
||||||
|
|
||||||
import { History } from 'lucide-react';
|
import { History, MessageSquareText } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { TicketDetail, TicketHistoryItem, TicketStatus } from '@/types';
|
import type { TicketDetail, TicketHistoryItem, TicketStatus } from '@/types';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
import { formatTicketStatus } from '../../components/TicketStatusBadge';
|
import { formatTicketStatus } from '../../components/TicketStatusBadge';
|
||||||
|
|
||||||
import { formatDate } from '@/utils/date';
|
|
||||||
|
|
||||||
function getActorLabel(item: TicketHistoryItem) {
|
function getActorLabel(item: TicketHistoryItem) {
|
||||||
return item.actor?.name || item.actor?.email || 'System';
|
return item.actor?.name || item.actor?.email || 'System';
|
||||||
}
|
}
|
||||||
@@ -61,7 +60,7 @@ function HistoryEntry({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</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">
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
{getLifecycleTitle(item.to_status)}
|
{getLifecycleTitle(item.to_status)}
|
||||||
@@ -74,12 +73,17 @@ function HistoryEntry({
|
|||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm leading-relaxed text-foreground">
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
{item.note ||
|
Updated By{' '}
|
||||||
`${formatTicketStatus(item.to_status)} by ${getActorLabel(item)}.`}
|
<span className="text-foreground/90">{getActorLabel(item)}</span>
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,86 +1,138 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import type { TicketActor, TicketDetail } from '@/types';
|
import type { TicketActor, TicketDetail } from '@/types';
|
||||||
|
|
||||||
import { TicketStatusBadge } from '../../components/TicketStatusBadge';
|
|
||||||
|
|
||||||
import { formatDate } from '@/utils/date';
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
function OverviewMeta({
|
import {
|
||||||
label,
|
TicketStatusBadge,
|
||||||
value,
|
} from '../../components/TicketStatusBadge';
|
||||||
}: {
|
|
||||||
label: string;
|
function formatCreatedDate(value: string | null | undefined) {
|
||||||
value: string | number | 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 (
|
return (
|
||||||
<div className="space-y-1">
|
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
{children}
|
||||||
<p className="text-sm font-medium">{value ?? '-'}</p>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PersonMeta({
|
function OverviewField({
|
||||||
label,
|
label,
|
||||||
person,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
person: TicketActor | null | undefined;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
<MetaLabel>{label}</MetaLabel>
|
||||||
{person ? (
|
{children}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
|
export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
const detectionCount = ticket.ai_result?.detection_count;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<section className="overflow-hidden rounded-xl border bg-card">
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
<div className="border-b px-4 py-2.5">
|
||||||
<CardTitle className="text-base">Overview</CardTitle>
|
<h2 className="text-xs font-medium ">
|
||||||
<TicketStatusBadge status={ticket.status} />
|
Overview
|
||||||
</CardHeader>
|
</h2>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-6 p-4 md:grid-cols-4">
|
||||||
<PersonMeta label="Uploaded By" person={ticket.uploader} />
|
<OverviewField label="Detection Count">
|
||||||
<PersonMeta label="Assigned To" person={ticket.worker} />
|
<MetaValue>
|
||||||
<PersonMeta
|
{detectionCount != null ? `${detectionCount} Occurrences` : '-'}
|
||||||
label="Assigned By"
|
</MetaValue>
|
||||||
person={ticket.worker?.assigned_by}
|
</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"
|
||||||
/>
|
/>
|
||||||
<OverviewMeta
|
</OverviewField>
|
||||||
label="Assigned At"
|
|
||||||
value={formatDate(ticket.worker?.assigned_at)}
|
<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>
|
</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>
|
</div>
|
||||||
</CardContent>
|
) : null}
|
||||||
</Card>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,33 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { ImageIcon } from 'lucide-react';
|
||||||
|
|
||||||
import type { TicketDetail } from '@/types';
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
import { RepairNotesCard } from './repair-report/RepairNotesCard';
|
|
||||||
import { RepairProofImagesGrid } from './repair-report/RepairProofImagesGrid';
|
import { RepairProofImagesGrid } from './repair-report/RepairProofImagesGrid';
|
||||||
import { RepairProofVideoCard } from './repair-report/RepairProofVideoCard';
|
import { RepairReportPanel } from './repair-report/RepairReportPanel';
|
||||||
import { ReviewerCommentsCard } from './repair-report/ReviewerCommentsCard';
|
import { RepairVideoComparison } from './repair-report/RepairVideoComparison';
|
||||||
|
|
||||||
export function TicketRepairReportCard({ ticket }: { ticket: TicketDetail }) {
|
export function TicketRepairReportCard({ ticket }: { ticket: TicketDetail }) {
|
||||||
const repair = ticket.repair;
|
const repair = ticket.repair;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-5">
|
||||||
<h2 className="text-base font-semibold text-foreground">
|
<RepairReportPanel ticket={ticket} />
|
||||||
Evidence & Progress
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<RepairVideoComparison ticket={ticket} />
|
||||||
<RepairProofVideoCard
|
|
||||||
ticketId={ticket.id}
|
<div className="space-y-3">
|
||||||
proofVideoUrl={repair?.proof_video_url ?? null}
|
<h3 className="flex items-center gap-2 text-base font-semibold">
|
||||||
hasRepair={Boolean(repair)}
|
<ImageIcon className="size-5 text-primary" />
|
||||||
/>
|
Repair Images
|
||||||
|
</h3>
|
||||||
|
|
||||||
<RepairProofImagesGrid
|
<RepairProofImagesGrid
|
||||||
imageUrls={repair?.proof_image_urls ?? []}
|
imageUrls={repair?.proof_image_urls ?? []}
|
||||||
submittedAt={repair?.submitted_at ?? null}
|
submittedAt={repair?.submitted_at ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { TicketDetail } from '@/types';
|
|||||||
import type { TicketDetailLiveState } from '../../hooks/useTicketDetailEvents';
|
import type { TicketDetailLiveState } from '../../hooks/useTicketDetailEvents';
|
||||||
|
|
||||||
import { AssignTicketAction } from './actions/AssignTicketAction';
|
import { AssignTicketAction } from './actions/AssignTicketAction';
|
||||||
|
import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
||||||
import { NoTicketAction } from './actions/NoTicketAction';
|
import { NoTicketAction } from './actions/NoTicketAction';
|
||||||
import { ProcessingTicketAction } from './actions/ProcessingTicketAction';
|
import { ProcessingTicketAction } from './actions/ProcessingTicketAction';
|
||||||
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
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} />;
|
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';
|
'use client';
|
||||||
|
|
||||||
import { CircleOff, Clock, Lock } from 'lucide-react';
|
import { CircleOff, Clock } from 'lucide-react';
|
||||||
|
|
||||||
import type { TicketStatus } from '@/types';
|
import type { TicketStatus } from '@/types';
|
||||||
|
|
||||||
import { TicketActionCard } from './TicketActionCard';
|
import { TicketActionCard } from './TicketActionCard';
|
||||||
|
|
||||||
const statusConfig: Record<
|
const statusConfig: Record<
|
||||||
'processing' | 'closed',
|
'processing',
|
||||||
{ icon: typeof Clock; title: string; message: string }
|
{ icon: typeof Clock; title: string; message: string }
|
||||||
> = {
|
> = {
|
||||||
processing: {
|
processing: {
|
||||||
@@ -15,16 +15,11 @@ const statusConfig: Record<
|
|||||||
title: 'Processing',
|
title: 'Processing',
|
||||||
message: 'This ticket is still being processed. Check back shortly.',
|
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 }) {
|
export function NoTicketAction({ status }: { status: TicketStatus }) {
|
||||||
const config =
|
const config =
|
||||||
status === 'processing' || status === 'closed'
|
status === 'processing'
|
||||||
? statusConfig[status]
|
? statusConfig[status]
|
||||||
: {
|
: {
|
||||||
icon: CircleOff,
|
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 MAX_MEDIA_SLOTS = 5;
|
||||||
|
|
||||||
const imageBadgeStyles = [
|
const imageBadgeStyles = [
|
||||||
'bg-slate-700/90 text-white',
|
'bg-primary/90 text-primary-foreground',
|
||||||
'bg-violet-600/90 text-white',
|
'bg-secondary text-secondary-foreground',
|
||||||
'bg-amber-600/90 text-white',
|
'bg-accent text-accent-foreground',
|
||||||
'bg-stone-600/90 text-white',
|
'bg-muted text-muted-foreground',
|
||||||
'bg-indigo-600/90 text-white',
|
'bg-primary/70 text-primary-foreground',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function RepairProofImagesGrid({
|
export function RepairProofImagesGrid({
|
||||||
imageUrls,
|
imageUrls,
|
||||||
submittedAt,
|
submittedAt,
|
||||||
|
layout = 'row',
|
||||||
}: {
|
}: {
|
||||||
imageUrls: string[];
|
imageUrls: string[];
|
||||||
submittedAt: string | null;
|
submittedAt: string | null;
|
||||||
|
layout?: 'row' | 'sidebar';
|
||||||
}) {
|
}) {
|
||||||
const imageSlots = Array.from(
|
const imageSlots = Array.from(
|
||||||
{ length: MAX_MEDIA_SLOTS },
|
{ length: MAX_MEDIA_SLOTS },
|
||||||
@@ -31,7 +33,14 @@ export function RepairProofImagesGrid({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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) => (
|
{imageSlots.map((url, index) => (
|
||||||
<RepairProofImageSlot
|
<RepairProofImageSlot
|
||||||
key={index}
|
key={index}
|
||||||
@@ -101,16 +110,8 @@ function RepairProofImageSlot({
|
|||||||
<img
|
<img
|
||||||
src={objectUrl}
|
src={objectUrl}
|
||||||
alt={`Repair proof ${index + 1}`}
|
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>
|
</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 ? 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
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 { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
import { TicketDetailHeader } from './components/TicketDetailHeader';
|
||||||
|
|
||||||
import { TicketHistoryCard } from './components/TicketHistoryCard';
|
import { TicketHistoryCard } from './components/TicketHistoryCard';
|
||||||
import { TicketOverviewCard } from './components/TicketOverviewCard';
|
import { TicketOverviewCard } from './components/TicketOverviewCard';
|
||||||
import { TicketRepairReportCard } from './components/TicketRepairReportCard';
|
import { TicketRepairReportCard } from './components/TicketRepairReportCard';
|
||||||
@@ -51,23 +52,9 @@ export default function TicketDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative z-10 space-y-5">
|
<main className="relative z-10 space-y-5">
|
||||||
<PageHeader
|
<TicketDetailHeader ticket={ticket} />
|
||||||
title="Ticket Detail"
|
|
||||||
description={`Ticket ID: ${ticket.id}`}
|
|
||||||
icon={Ticket}
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => router.push('/ticket')}
|
|
||||||
>
|
|
||||||
<ArrowLeft />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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">
|
<div className="space-y-5">
|
||||||
<TicketOverviewCard ticket={ticket} />
|
<TicketOverviewCard ticket={ticket} />
|
||||||
<TicketRepairReportCard ticket={ticket} />
|
<TicketRepairReportCard ticket={ticket} />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const options = [
|
|||||||
{ value: 'yoloe_trained_vl', label: 'YOLOE With Vision Language Model' },
|
{ value: 'yoloe_trained_vl', label: 'YOLOE With Vision Language Model' },
|
||||||
{ value: 'culvert_detection', label: 'Culvert Detection' },
|
{ value: 'culvert_detection', label: 'Culvert Detection' },
|
||||||
{ value: 'combined', label: 'Road Defect Detection with vl' },
|
{ value: 'combined', label: 'Road Defect Detection with vl' },
|
||||||
|
{ value: 'yoloe_seg', label: 'Improved Road Defect Detection' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
interface UploadSettingsSectionProps {
|
interface UploadSettingsSectionProps {
|
||||||
|
|||||||
112
src/components/media/ProtectedVideoPlayer.tsx
Normal file
112
src/components/media/ProtectedVideoPlayer.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Video } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ProtectedVideoPlayerProps {
|
||||||
|
url: string | null | undefined;
|
||||||
|
label?: string;
|
||||||
|
meta?: string;
|
||||||
|
emptyTitle?: string;
|
||||||
|
emptyDescription?: string;
|
||||||
|
unavailableTitle?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedVideoPlayer({
|
||||||
|
url,
|
||||||
|
label,
|
||||||
|
meta,
|
||||||
|
emptyTitle = 'No video available',
|
||||||
|
emptyDescription,
|
||||||
|
unavailableTitle = 'Video unavailable',
|
||||||
|
className,
|
||||||
|
}: ProtectedVideoPlayerProps) {
|
||||||
|
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return (
|
||||||
|
<VideoState
|
||||||
|
title={emptyTitle}
|
||||||
|
description={emptyDescription}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
className={cn('aspect-video w-full rounded-md border', className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !objectUrl) {
|
||||||
|
return (
|
||||||
|
<VideoState
|
||||||
|
title={unavailableTitle}
|
||||||
|
description="The video could not be loaded right now."
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group relative overflow-hidden rounded-md border bg-black/90',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
src={objectUrl}
|
||||||
|
className="aspect-video w-full object-cover"
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
|
||||||
|
{label ? (
|
||||||
|
<span className="absolute top-3 left-3 rounded bg-black/65 px-2.5 py-1 text-[11px] font-semibold text-white backdrop-blur-sm">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{meta ? (
|
||||||
|
<span className="pointer-events-none absolute top-3 right-3 max-w-[55%] truncate text-xs font-medium">
|
||||||
|
{meta}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoState({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex aspect-video flex-col items-center justify-center rounded-md border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Video className="mb-3 size-6 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||||
|
{description ? (
|
||||||
|
<p className="mt-1 max-w-xs text-xs text-muted-foreground/80">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user