refactor: refactor report proff section
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -29,6 +29,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"libphonenumber-js": "^1.13.7",
|
"libphonenumber-js": "^1.13.7",
|
||||||
|
"lightgallery": "^2.9.0",
|
||||||
"lucide-react": "^0.548.0",
|
"lucide-react": "^0.548.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
@@ -6876,6 +6877,15 @@
|
|||||||
"integrity": "sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==",
|
"integrity": "sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lightgallery": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightgallery/-/lightgallery-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-58Ud1DyhD2ao58t+kPEqSZrjFxg23tGd5ZKr75erm7q31g5xhUtWUJH3sTUkhHzlyJAKHj5eTrJ37HQRXG4Wbg==",
|
||||||
|
"license": "GPLv3",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.31.1",
|
"version": "1.31.1",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"libphonenumber-js": "^1.13.7",
|
"libphonenumber-js": "^1.13.7",
|
||||||
|
"lightgallery": "^2.9.0",
|
||||||
"lucide-react": "^0.548.0",
|
"lucide-react": "^0.548.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ImageIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import type { TicketDetail } from '@/types';
|
|
||||||
|
|
||||||
import { RepairProofImagesGrid } from './repair-report/RepairProofImagesGrid';
|
|
||||||
import { RepairReportPanel } from './repair-report/RepairReportPanel';
|
|
||||||
import { RepairVideoComparison } from './repair-report/RepairVideoComparison';
|
|
||||||
|
|
||||||
export function TicketRepairReportCard({ ticket }: { ticket: TicketDetail }) {
|
|
||||||
const repair = ticket.repair;
|
|
||||||
|
|
||||||
if (!repair) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-5">
|
|
||||||
<RepairReportPanel ticket={ticket} />
|
|
||||||
|
|
||||||
<RepairVideoComparison ticket={ticket} />
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<ImageIcon className="size-5 text-primary" />
|
|
||||||
Repair Images
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<RepairProofImagesGrid
|
|
||||||
imageUrls={repair?.proof_image_urls ?? []}
|
|
||||||
submittedAt={repair?.submitted_at ?? null}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@ import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
|||||||
import { NoTicketAction } from './actions/NoTicketAction';
|
import { NoTicketAction } from './actions/NoTicketAction';
|
||||||
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
||||||
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
||||||
|
import { SubmittedRepairProofAction } from './actions/SubmittedRepairProofAction';
|
||||||
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
||||||
|
|
||||||
interface TicketStatusActionsProps {
|
interface TicketStatusActionsProps {
|
||||||
@@ -67,5 +68,15 @@ function getTicketAction(ticket: TicketDetail) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
|
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
|
||||||
return getTicketAction(ticket);
|
const action = getTicketAction(ticket);
|
||||||
|
const submittedProof = ticket.repair?.submitted_at ? (
|
||||||
|
<SubmittedRepairProofAction ticket={ticket} />
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{submittedProof}
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ type RepairActionTab = 'repair-proof' | 'request-extension';
|
|||||||
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
|
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
|
||||||
const [activeTab, setActiveTab] = useState<RepairActionTab>('repair-proof');
|
const [activeTab, setActiveTab] = useState<RepairActionTab>('repair-proof');
|
||||||
|
|
||||||
|
if (ticket.repair?.submitted_at) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TicketActionCard icon={Wrench} title="Repair Proof">
|
<TicketActionCard icon={Wrench} title="Repair Proof">
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { RepairProofSummary as SubmittedRepairProofAction } from '../repair/RepairProofSummary';
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ImageIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { formatDate } from '@/utils/date';
|
|
||||||
|
|
||||||
import { ReportSurface } from './repairReportUtils';
|
|
||||||
|
|
||||||
const MAX_MEDIA_SLOTS = 5;
|
|
||||||
|
|
||||||
const imageBadgeStyles = [
|
|
||||||
'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 },
|
|
||||||
(_, index) => imageUrls[index] ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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}
|
|
||||||
url={url}
|
|
||||||
index={index}
|
|
||||||
submittedAt={submittedAt}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RepairProofImageSlot({
|
|
||||||
url,
|
|
||||||
index,
|
|
||||||
submittedAt,
|
|
||||||
}: {
|
|
||||||
url: string | null;
|
|
||||||
index: number;
|
|
||||||
submittedAt: string | null;
|
|
||||||
}) {
|
|
||||||
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url);
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return (
|
|
||||||
<ReportSurface className="space-y-2 p-2">
|
|
||||||
<div className="flex aspect-4/3 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/10 text-muted-foreground/70">
|
|
||||||
<ImageIcon className="size-4" />
|
|
||||||
</div>
|
|
||||||
<p className="truncate px-1 text-[11px] text-muted-foreground/70">
|
|
||||||
No image added
|
|
||||||
</p>
|
|
||||||
</ReportSurface>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<ReportSurface className="space-y-2 p-2">
|
|
||||||
<Skeleton className="aspect-4/3 w-full rounded-lg" />
|
|
||||||
<Skeleton className="h-3 w-16" />
|
|
||||||
</ReportSurface>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !objectUrl) {
|
|
||||||
return (
|
|
||||||
<ReportSurface className="space-y-2 p-2">
|
|
||||||
<div className="flex aspect-4/3 items-center justify-center rounded-lg border border-dashed bg-muted/10 text-xs text-muted-foreground">
|
|
||||||
Unavailable
|
|
||||||
</div>
|
|
||||||
<p className="truncate px-1 text-[11px] text-muted-foreground">
|
|
||||||
Image {index + 1}
|
|
||||||
</p>
|
|
||||||
</ReportSurface>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ReportSurface className="space-y-2 p-2">
|
|
||||||
<a
|
|
||||||
href={objectUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="group relative block overflow-hidden rounded-lg border bg-muted/20"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={objectUrl}
|
|
||||||
alt={`Repair proof ${index + 1}`}
|
|
||||||
className="aspect-4/3 w-full object-cover transition-transform group-hover:scale-[1.02]"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
<p className="truncate px-1 text-[11px] text-muted-foreground">
|
|
||||||
{submittedAt ? formatDate(submittedAt) : `Image ${index + 1}`}
|
|
||||||
</p>
|
|
||||||
</ReportSurface>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { FileText, MessageSquareQuote } from 'lucide-react';
|
|
||||||
|
|
||||||
import { PersonInfo } from '@/components/person-avatar';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import type { TicketDetail } from '@/types';
|
|
||||||
import { formatDate } from '@/utils/date';
|
|
||||||
|
|
||||||
import { EmptyPanel } 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 (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<FileText className="size-5 text-primary" />
|
|
||||||
Repair Report
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<EmptyPanel
|
|
||||||
icon={MessageSquareQuote}
|
|
||||||
title="No repair report submitted yet"
|
|
||||||
description="Repair details will appear here once the assigned worker submits evidence."
|
|
||||||
className="py-10"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="sm:grid-cols-[1fr_auto]">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<FileText className="size-5 text-primary" />
|
|
||||||
Repair Report
|
|
||||||
</CardTitle>
|
|
||||||
{repair.submitted_at ? (
|
|
||||||
<span className="text-xs text-muted-foreground italic sm:col-start-2 sm:row-start-1">
|
|
||||||
Completed {formatDate(repair.submitted_at)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-5">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<MetaLabel>Technician</MetaLabel>
|
|
||||||
<PersonInfo person={ticket.worker} />
|
|
||||||
</div>
|
|
||||||
<ReportField
|
|
||||||
label="Submitted At"
|
|
||||||
value={
|
|
||||||
repair.submitted_at ? formatDate(repair.submitted_at) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<EmptyPanel
|
|
||||||
icon={MessageSquareQuote}
|
|
||||||
title="No repair notes added yet"
|
|
||||||
className="py-6"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ShieldCheck } from 'lucide-react';
|
|
||||||
|
|
||||||
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
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 (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<ShieldCheck className="size-5 text-primary" />
|
|
||||||
Repair Verification
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent 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="Contractor repair evidence video"
|
|
||||||
videoLabel="After"
|
|
||||||
videoUrl={repairVideoUrl}
|
|
||||||
meta="Contractor 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.'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,56 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { LucideIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<p className="text-[11px] font-semibold tracking-[0.12em] text-foreground uppercase">
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReportSurface({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={cn('rounded-lg border bg-muted/15 p-3', className)}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmptyPanel({
|
|
||||||
icon: Icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
icon: LucideIcon;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col items-center justify-center rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useQueries } from '@tanstack/react-query';
|
||||||
|
import LightGallery from 'lightgallery/react';
|
||||||
|
import lgThumbnail from 'lightgallery/plugins/thumbnail';
|
||||||
|
import lgZoom from 'lightgallery/plugins/zoom';
|
||||||
|
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { protectedMediaService } from '@/services/api';
|
||||||
|
|
||||||
|
interface RepairProofGalleryProps {
|
||||||
|
imageUrls: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_VISIBLE_IMAGES = 8;
|
||||||
|
|
||||||
|
export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
|
||||||
|
const queries = useQueries({
|
||||||
|
queries: imageUrls.map((url) => ({
|
||||||
|
queryKey: ['protected-media', 'v2', url],
|
||||||
|
queryFn: () => protectedMediaService.getBlob(url),
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: 1000 * 60 * 30,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const blobs = queries.map((query) => query.data);
|
||||||
|
const objectUrls = useMemo(
|
||||||
|
() => blobs.map((blob) => (blob ? URL.createObjectURL(blob) : null)),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[...blobs],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
objectUrls.forEach((url) => {
|
||||||
|
if (url) URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[objectUrls],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (queries.some((query) => query.isLoading)) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 gap-1 overflow-hidden rounded-xl">
|
||||||
|
{imageUrls.slice(0, MAX_VISIBLE_IMAGES).map((url, index) => (
|
||||||
|
<Skeleton
|
||||||
|
key={`${url}-${index}`}
|
||||||
|
className="aspect-square rounded-none"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = objectUrls.flatMap((url, index) =>
|
||||||
|
url ? [{ url, originalIndex: index }] : [],
|
||||||
|
);
|
||||||
|
const visibleCount = Math.min(MAX_VISIBLE_IMAGES, images.length);
|
||||||
|
const overflowCount = images.length - visibleCount;
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Repair images are unavailable.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LightGallery
|
||||||
|
elementClassNames="grid grid-cols-4 gap-1 overflow-hidden rounded-xl"
|
||||||
|
mode="lg-fade"
|
||||||
|
speed={400}
|
||||||
|
plugins={[lgThumbnail, lgZoom]}
|
||||||
|
thumbnail
|
||||||
|
download={false}
|
||||||
|
mobileSettings={{ controls: true, showCloseIcon: true, download: false }}
|
||||||
|
>
|
||||||
|
{images.map(({ url, originalIndex }, index) => {
|
||||||
|
const isVisible = index < visibleCount;
|
||||||
|
const isOverflowTile = overflowCount > 0 && index === visibleCount - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={imageUrls[originalIndex]}
|
||||||
|
data-src={url}
|
||||||
|
data-sub-html={`<p>Repair evidence ${originalIndex + 1}</p>`}
|
||||||
|
className={isVisible ? 'group relative aspect-square' : 'hidden'}
|
||||||
|
aria-label={
|
||||||
|
isOverflowTile
|
||||||
|
? `View all ${images.length} repair images`
|
||||||
|
: `View repair image ${originalIndex + 1} of ${images.length}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Blob URLs cannot be handled by next/image. */}
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={`Repair evidence ${originalIndex + 1}`}
|
||||||
|
className="size-full object-cover transition duration-300 group-hover:scale-[1.03] group-hover:brightness-90"
|
||||||
|
/>
|
||||||
|
{isOverflowTile ? (
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center bg-black/55 text-xl font-semibold text-white transition-colors group-hover:bg-black/65">
|
||||||
|
+{overflowCount}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</LightGallery>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { CheckCircle2, HardHat, Wrench } from 'lucide-react';
|
||||||
|
|
||||||
|
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
import { RepairProofGallery } from './RepairProofGallery';
|
||||||
|
|
||||||
|
function SubmittedBanner({
|
||||||
|
submittedAt,
|
||||||
|
submitterName,
|
||||||
|
}: {
|
||||||
|
submittedAt: string;
|
||||||
|
submitterName: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-3 dark:border-emerald-900/60 dark:bg-emerald-950/30">
|
||||||
|
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
<div className="min-w-0 space-y-0.5">
|
||||||
|
<p className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
|
||||||
|
Repair Submitted
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-emerald-700/80 dark:text-emerald-300/80">
|
||||||
|
{formatDate(submittedAt)} by {submitterName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageGallery({
|
||||||
|
imageUrls,
|
||||||
|
hasVideo,
|
||||||
|
}: {
|
||||||
|
imageUrls: string[];
|
||||||
|
hasVideo: boolean;
|
||||||
|
}) {
|
||||||
|
if (imageUrls.length === 0) {
|
||||||
|
return hasVideo ? null : (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-dashed px-3 py-4 text-sm text-muted-foreground">
|
||||||
|
<HardHat className="size-4 shrink-0" />
|
||||||
|
No media attached
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <RepairProofGallery imageUrls={imageUrls} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Notes({ notes }: { notes: string | null | undefined }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-semibold text-foreground">Repair Notes</p>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{notes?.trim() || 'No repair notes provided.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepairProofSummary({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
const repair = ticket.repair;
|
||||||
|
|
||||||
|
if (!repair?.submitted_at) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitterName = ticket.worker?.name?.trim() || 'Assigned worker';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Wrench className="size-4 text-primary" />
|
||||||
|
Repair Proof
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<SubmittedBanner
|
||||||
|
submittedAt={repair.submitted_at}
|
||||||
|
submitterName={submitterName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProtectedVideoPlayer
|
||||||
|
url={repair.proof_video_url}
|
||||||
|
meta="Contractor evidence video"
|
||||||
|
emptyTitle="No repair video uploaded"
|
||||||
|
emptyDescription="The assigned worker has not uploaded a repair video."
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImageGallery
|
||||||
|
imageUrls={repair.proof_image_urls}
|
||||||
|
hasVideo={Boolean(repair.proof_video_url)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Notes notes={repair.notes} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
|
|
||||||
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 { TicketStatusActions } from './components/TicketStatusActions';
|
import { TicketStatusActions } from './components/TicketStatusActions';
|
||||||
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
|
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
|
||||||
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
|
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
|
||||||
@@ -75,10 +74,7 @@ export default function TicketDetailPage() {
|
|||||||
)}
|
)}
|
||||||
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
|
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
|
||||||
{!isClassDetailLoading && classDetail ? (
|
{!isClassDetailLoading && classDetail ? (
|
||||||
<>
|
|
||||||
<TicketRepairReportCard ticket={classDetail} />
|
|
||||||
<TicketHistoryCard ticket={classDetail} />
|
<TicketHistoryCard ticket={classDetail} />
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import type { Metadata } from 'next';
|
|||||||
import { Geist_Mono, Poppins } from 'next/font/google';
|
import { Geist_Mono, Poppins } from 'next/font/google';
|
||||||
import '@vidstack/react/player/styles/default/theme.css';
|
import '@vidstack/react/player/styles/default/theme.css';
|
||||||
import '@vidstack/react/player/styles/default/layouts/video.css';
|
import '@vidstack/react/player/styles/default/layouts/video.css';
|
||||||
|
import 'lightgallery/css/lightgallery.css';
|
||||||
|
import 'lightgallery/css/lg-thumbnail.css';
|
||||||
|
import 'lightgallery/css/lg-zoom.css';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { ThemeProvider } from '@/providers/ThemeProvider';
|
import { ThemeProvider } from '@/providers/ThemeProvider';
|
||||||
import AppInitializer from '@/providers/AppInitializer';
|
import AppInitializer from '@/providers/AppInitializer';
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export function ProtectedVideoPlayer({
|
|||||||
unavailableTitle = 'Video unavailable',
|
unavailableTitle = 'Video unavailable',
|
||||||
className,
|
className,
|
||||||
}: ProtectedVideoPlayerProps) {
|
}: ProtectedVideoPlayerProps) {
|
||||||
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url);
|
const { objectUrl, mediaType, isLoading, isError } =
|
||||||
|
useProtectedMediaObjectUrl(url);
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return (
|
return (
|
||||||
@@ -73,11 +74,14 @@ export function ProtectedVideoPlayer({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
src={objectUrl}
|
key={objectUrl}
|
||||||
className="aspect-video w-full object-cover"
|
className="aspect-video w-full bg-black object-contain"
|
||||||
controls
|
controls
|
||||||
playsInline
|
playsInline
|
||||||
/>
|
preload="metadata"
|
||||||
|
>
|
||||||
|
<source src={objectUrl} type={mediaType ?? 'video/mp4'} />
|
||||||
|
</video>
|
||||||
|
|
||||||
{label ? (
|
{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">
|
<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">
|
||||||
@@ -86,7 +90,7 @@ export function ProtectedVideoPlayer({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{meta ? (
|
{meta ? (
|
||||||
<span className="pointer-events-none absolute top-3 right-3 max-w-[55%] truncate text-xs font-medium">
|
<span className="pointer-events-none absolute top-3 right-3 max-w-[55%] truncate text-xs font-medium text-white/90">
|
||||||
{meta}
|
{meta}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,38 +1,41 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { protectedMediaService } from '@/services/api';
|
import { protectedMediaService } from '@/services/api';
|
||||||
|
|
||||||
export function useProtectedMediaObjectUrl(url: string | null | undefined) {
|
export function useProtectedMediaObjectUrl(url: string | null | undefined) {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['protected-media', url],
|
queryKey: ['protected-media', 'v2', url],
|
||||||
queryFn: () => protectedMediaService.getBlob(url as string),
|
queryFn: () => protectedMediaService.getBlob(url as string),
|
||||||
enabled: Boolean(url),
|
enabled: Boolean(url),
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
gcTime: 1000 * 60 * 30,
|
gcTime: 1000 * 60 * 30,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
const objectUrl = useMemo(() => {
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!query.data) {
|
if (!query.data) {
|
||||||
setObjectUrl(null);
|
return null;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextUrl = URL.createObjectURL(query.data);
|
return URL.createObjectURL(query.data);
|
||||||
setObjectUrl(nextUrl);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
URL.revokeObjectURL(nextUrl);
|
|
||||||
};
|
|
||||||
}, [query.data]);
|
}, [query.data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [objectUrl]);
|
||||||
|
|
||||||
|
const isLoading = query.isLoading || (Boolean(query.data) && !objectUrl);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
objectUrl,
|
objectUrl,
|
||||||
isLoading: query.isLoading,
|
mediaType: query.data?.type ?? null,
|
||||||
|
isLoading,
|
||||||
isError: query.isError,
|
isError: query.isError,
|
||||||
error: query.error,
|
error: query.error,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,49 @@
|
|||||||
import axiosClient from '../axios/axios';
|
import axiosClient from '../axios/axios';
|
||||||
|
|
||||||
|
const MIME_BY_EXTENSION: Record<string, string> = {
|
||||||
|
mp4: 'video/mp4',
|
||||||
|
webm: 'video/webm',
|
||||||
|
mov: 'video/quicktime',
|
||||||
|
m4v: 'video/mp4',
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
gif: 'image/gif',
|
||||||
|
webp: 'image/webp',
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeMediaBlob(url: string, blob: Blob): Blob {
|
||||||
|
if (blob.type && blob.type !== 'application/octet-stream') {
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = url.split('?')[0].split('.').pop()?.toLowerCase();
|
||||||
|
const mimeType = extension ? MIME_BY_EXTENSION[extension] : undefined;
|
||||||
|
|
||||||
|
if (!mimeType) {
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([blob], { type: mimeType });
|
||||||
|
}
|
||||||
|
|
||||||
export const protectedMediaService = {
|
export const protectedMediaService = {
|
||||||
getBlob: async (url: string): Promise<Blob> => {
|
getBlob: async (url: string): Promise<Blob> => {
|
||||||
const response = await axiosClient.get<Blob>(url, {
|
const response = await axiosClient.get<Blob>(url, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
headers: {
|
||||||
|
Accept: '*/*',
|
||||||
|
},
|
||||||
|
transformRequest: [
|
||||||
|
(data, headers) => {
|
||||||
|
if (headers) {
|
||||||
|
delete headers['Content-Type'];
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
return response.data;
|
|
||||||
|
return normalizeMediaBlob(url, response.data);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user