refactor: refactor report proff section

This commit is contained in:
2026-07-06 17:04:05 +05:30
parent c0f3a718ef
commit ba9731de86
17 changed files with 315 additions and 430 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}