From ba9731de86d59bcc18123da368d4a79cb8cbb710 Mon Sep 17 00:00:00 2001
From: "santasri.pachhal"
Date: Mon, 6 Jul 2026 17:04:05 +0530
Subject: [PATCH] refactor: refactor report proff section
---
package-lock.json | 10 ++
package.json | 1 +
.../components/TicketRepairReportCard.tsx | 42 ------
.../components/TicketStatusActions.tsx | 13 +-
.../components/actions/SubmitRepairAction.tsx | 4 +
.../actions/SubmittedRepairProofAction.tsx | 1 +
.../repair-report/RepairProofImagesGrid.tsx | 122 ------------------
.../repair-report/RepairReportPanel.tsx | 106 ---------------
.../repair-report/RepairVideoComparison.tsx | 78 -----------
.../repair-report/repairReportUtils.tsx | 56 --------
.../components/repair/RepairProofGallery.tsx | 114 ++++++++++++++++
.../components/repair/RepairProofSummary.tsx | 103 +++++++++++++++
src/app/(modules)/ticket/[ticketId]/page.tsx | 6 +-
src/app/layout.tsx | 3 +
src/components/media/ProtectedVideoPlayer.tsx | 14 +-
src/hooks/useProtectedMedia.ts | 31 +++--
src/services/api/media.service.ts | 41 +++++-
17 files changed, 315 insertions(+), 430 deletions(-)
delete mode 100644 src/app/(modules)/ticket/[ticketId]/components/TicketRepairReportCard.tsx
create mode 100644 src/app/(modules)/ticket/[ticketId]/components/actions/SubmittedRepairProofAction.tsx
delete mode 100644 src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairProofImagesGrid.tsx
delete mode 100644 src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairReportPanel.tsx
delete mode 100644 src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairVideoComparison.tsx
delete mode 100644 src/app/(modules)/ticket/[ticketId]/components/repair-report/repairReportUtils.tsx
create mode 100644 src/app/(modules)/ticket/[ticketId]/components/repair/RepairProofGallery.tsx
create mode 100644 src/app/(modules)/ticket/[ticketId]/components/repair/RepairProofSummary.tsx
diff --git a/package-lock.json b/package-lock.json
index 80618a3..d818001 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"libphonenumber-js": "^1.13.7",
+ "lightgallery": "^2.9.0",
"lucide-react": "^0.548.0",
"motion": "^12.38.0",
"next": "16.0.10",
@@ -6876,6 +6877,15 @@
"integrity": "sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==",
"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": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
diff --git a/package.json b/package.json
index bab90e3..71800d2 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"libphonenumber-js": "^1.13.7",
+ "lightgallery": "^2.9.0",
"lucide-react": "^0.548.0",
"motion": "^12.38.0",
"next": "16.0.10",
diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketRepairReportCard.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketRepairReportCard.tsx
deleted file mode 100644
index 342324f..0000000
--- a/src/app/(modules)/ticket/[ticketId]/components/TicketRepairReportCard.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
-
-
-
- Repair Images
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketStatusActions.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketStatusActions.tsx
index f39a15c..efd4cac 100644
--- a/src/app/(modules)/ticket/[ticketId]/components/TicketStatusActions.tsx
+++ b/src/app/(modules)/ticket/[ticketId]/components/TicketStatusActions.tsx
@@ -9,6 +9,7 @@ import { ClosedTicketAction } from './actions/ClosedTicketAction';
import { NoTicketAction } from './actions/NoTicketAction';
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
import { ReviewRepairAction } from './actions/ReviewRepairAction';
+import { SubmittedRepairProofAction } from './actions/SubmittedRepairProofAction';
import { SubmitRepairAction } from './actions/SubmitRepairAction';
interface TicketStatusActionsProps {
@@ -67,5 +68,15 @@ function getTicketAction(ticket: TicketDetail) {
}
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
- return getTicketAction(ticket);
+ const action = getTicketAction(ticket);
+ const submittedProof = ticket.repair?.submitted_at ? (
+
+ ) : null;
+
+ return (
+
+ {submittedProof}
+ {action}
+
+ );
}
diff --git a/src/app/(modules)/ticket/[ticketId]/components/actions/SubmitRepairAction.tsx b/src/app/(modules)/ticket/[ticketId]/components/actions/SubmitRepairAction.tsx
index c81b244..728faf3 100644
--- a/src/app/(modules)/ticket/[ticketId]/components/actions/SubmitRepairAction.tsx
+++ b/src/app/(modules)/ticket/[ticketId]/components/actions/SubmitRepairAction.tsx
@@ -15,6 +15,10 @@ type RepairActionTab = 'repair-proof' | 'request-extension';
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
const [activeTab, setActiveTab] = useState('repair-proof');
+ if (ticket.repair?.submitted_at) {
+ return null;
+ }
+
return (
imageUrls[index] ?? null,
- );
-
- return (
-
- {imageSlots.map((url, index) => (
-
- ))}
-
- );
-}
-
-function RepairProofImageSlot({
- url,
- index,
- submittedAt,
-}: {
- url: string | null;
- index: number;
- submittedAt: string | null;
-}) {
- const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url);
-
- if (!url) {
- return (
-
-
-
-
-
- No image added
-
-
- );
- }
-
- if (isLoading) {
- return (
-
-
-
-
- );
- }
-
- if (isError || !objectUrl) {
- return (
-
-
- Unavailable
-
-
- Image {index + 1}
-
-
- );
- }
-
- return (
-
-
-
-
-
- {submittedAt ? formatDate(submittedAt) : `Image ${index + 1}`}
-
-
- );
-}
diff --git a/src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairReportPanel.tsx b/src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairReportPanel.tsx
deleted file mode 100644
index a69fcd1..0000000
--- a/src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairReportPanel.tsx
+++ /dev/null
@@ -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 (
-
- {children}
-
- );
-}
-
-function ReportField({
- label,
- value,
-}: {
- label: string;
- value: string | null | undefined;
-}) {
- return (
-
-
{label}
-
{value || '-'}
-
- );
-}
-
-export function RepairReportPanel({ ticket }: { ticket: TicketDetail }) {
- const repair = ticket.repair;
-
- if (!repair) {
- return (
-
-
-
-
- Repair Report
-
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
- Repair Report
-
- {repair.submitted_at ? (
-
- Completed {formatDate(repair.submitted_at)}
-
- ) : null}
-
-
-
-
-
-
-
Repair Summary
- {repair.notes?.trim() ? (
-
- ) : (
-
- )}
-
-
-
- );
-}
diff --git a/src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairVideoComparison.tsx b/src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairVideoComparison.tsx
deleted file mode 100644
index cf00314..0000000
--- a/src/app/(modules)/ticket/[ticketId]/components/repair-report/RepairVideoComparison.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- Repair Verification
-
-
-
-
-
-
-
-
- );
-}
-
-function ComparisonVideoItem({
- tooltip,
- videoLabel,
- videoUrl,
- meta,
- emptyTitle,
- emptyDescription,
-}: {
- tooltip: string;
- videoLabel: string;
- videoUrl: string | null;
- meta: string;
- emptyTitle: string;
- emptyDescription: string;
-}) {
- return (
-
- );
-}
diff --git a/src/app/(modules)/ticket/[ticketId]/components/repair-report/repairReportUtils.tsx b/src/app/(modules)/ticket/[ticketId]/components/repair-report/repairReportUtils.tsx
deleted file mode 100644
index 93b443a..0000000
--- a/src/app/(modules)/ticket/[ticketId]/components/repair-report/repairReportUtils.tsx
+++ /dev/null
@@ -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 (
-
- {children}
-
- );
-}
-
-export function ReportSurface({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return (
-
- {children}
-
- );
-}
-
-export function EmptyPanel({
- icon: Icon,
- title,
- description,
- className,
-}: {
- icon: LucideIcon;
- title: string;
- description?: string;
- className?: string;
-}) {
- return (
-
-
-
{title}
- {description ? (
-
- {description}
-
- ) : null}
-
- );
-}
diff --git a/src/app/(modules)/ticket/[ticketId]/components/repair/RepairProofGallery.tsx b/src/app/(modules)/ticket/[ticketId]/components/repair/RepairProofGallery.tsx
new file mode 100644
index 0000000..3152b58
--- /dev/null
+++ b/src/app/(modules)/ticket/[ticketId]/components/repair/RepairProofGallery.tsx
@@ -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 (
+
+ {imageUrls.slice(0, MAX_VISIBLE_IMAGES).map((url, index) => (
+
+ ))}
+
+ );
+ }
+
+ 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 (
+
+ Repair images are unavailable.
+
+ );
+ }
+
+ return (
+
+ {images.map(({ url, originalIndex }, index) => {
+ const isVisible = index < visibleCount;
+ const isOverflowTile = overflowCount > 0 && index === visibleCount - 1;
+
+ return (
+ Repair evidence ${originalIndex + 1}
`}
+ 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 */}
+
+ {isOverflowTile ? (
+
+ +{overflowCount}
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/app/(modules)/ticket/[ticketId]/components/repair/RepairProofSummary.tsx b/src/app/(modules)/ticket/[ticketId]/components/repair/RepairProofSummary.tsx
new file mode 100644
index 0000000..5e57e0a
--- /dev/null
+++ b/src/app/(modules)/ticket/[ticketId]/components/repair/RepairProofSummary.tsx
@@ -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 (
+
+
+
+
+ Repair Submitted
+
+
+ {formatDate(submittedAt)} by {submitterName}
+
+
+
+ );
+}
+
+function ImageGallery({
+ imageUrls,
+ hasVideo,
+}: {
+ imageUrls: string[];
+ hasVideo: boolean;
+}) {
+ if (imageUrls.length === 0) {
+ return hasVideo ? null : (
+
+
+ No media attached
+
+ );
+ }
+
+ return ;
+}
+
+function Notes({ notes }: { notes: string | null | undefined }) {
+ return (
+
+
Repair Notes
+
+ {notes?.trim() || 'No repair notes provided.'}
+
+
+ );
+}
+
+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 (
+
+
+
+
+ Repair Proof
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(modules)/ticket/[ticketId]/page.tsx b/src/app/(modules)/ticket/[ticketId]/page.tsx
index 420c87f..6b3aeef 100644
--- a/src/app/(modules)/ticket/[ticketId]/page.tsx
+++ b/src/app/(modules)/ticket/[ticketId]/page.tsx
@@ -17,7 +17,6 @@ import {
import { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketOverviewCard } from './components/TicketOverviewCard';
-import { TicketRepairReportCard } from './components/TicketRepairReportCard';
import { TicketStatusActions } from './components/TicketStatusActions';
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
@@ -75,10 +74,7 @@ export default function TicketDetailPage() {
)}
{isClassDetailLoading ? : null}
{!isClassDetailLoading && classDetail ? (
- <>
-
-
- >
+
) : null}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 06b8dc7..1bb4956 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,6 +2,9 @@ import type { Metadata } from 'next';
import { Geist_Mono, Poppins } from 'next/font/google';
import '@vidstack/react/player/styles/default/theme.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 { ThemeProvider } from '@/providers/ThemeProvider';
import AppInitializer from '@/providers/AppInitializer';
diff --git a/src/components/media/ProtectedVideoPlayer.tsx b/src/components/media/ProtectedVideoPlayer.tsx
index 0cc7c74..687c0aa 100644
--- a/src/components/media/ProtectedVideoPlayer.tsx
+++ b/src/components/media/ProtectedVideoPlayer.tsx
@@ -24,7 +24,8 @@ export function ProtectedVideoPlayer({
unavailableTitle = 'Video unavailable',
className,
}: ProtectedVideoPlayerProps) {
- const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url);
+ const { objectUrl, mediaType, isLoading, isError } =
+ useProtectedMediaObjectUrl(url);
if (!url) {
return (
@@ -73,11 +74,14 @@ export function ProtectedVideoPlayer({
)}
>
+ preload="metadata"
+ >
+
+
{label ? (
@@ -86,7 +90,7 @@ export function ProtectedVideoPlayer({
) : null}
{meta ? (
-
+
{meta}
) : null}
diff --git a/src/hooks/useProtectedMedia.ts b/src/hooks/useProtectedMedia.ts
index 3385658..d1b0baa 100644
--- a/src/hooks/useProtectedMedia.ts
+++ b/src/hooks/useProtectedMedia.ts
@@ -1,38 +1,41 @@
'use client';
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { protectedMediaService } from '@/services/api';
export function useProtectedMediaObjectUrl(url: string | null | undefined) {
const query = useQuery({
- queryKey: ['protected-media', url],
+ queryKey: ['protected-media', 'v2', url],
queryFn: () => protectedMediaService.getBlob(url as string),
enabled: Boolean(url),
staleTime: Infinity,
gcTime: 1000 * 60 * 30,
});
- const [objectUrl, setObjectUrl] = useState(null);
-
- useEffect(() => {
+ const objectUrl = useMemo(() => {
if (!query.data) {
- setObjectUrl(null);
- return;
+ return null;
}
- const nextUrl = URL.createObjectURL(query.data);
- setObjectUrl(nextUrl);
-
- return () => {
- URL.revokeObjectURL(nextUrl);
- };
+ return URL.createObjectURL(query.data);
}, [query.data]);
+ useEffect(() => {
+ return () => {
+ if (objectUrl) {
+ URL.revokeObjectURL(objectUrl);
+ }
+ };
+ }, [objectUrl]);
+
+ const isLoading = query.isLoading || (Boolean(query.data) && !objectUrl);
+
return {
objectUrl,
- isLoading: query.isLoading,
+ mediaType: query.data?.type ?? null,
+ isLoading,
isError: query.isError,
error: query.error,
};
diff --git a/src/services/api/media.service.ts b/src/services/api/media.service.ts
index 0dce3e5..26018aa 100644
--- a/src/services/api/media.service.ts
+++ b/src/services/api/media.service.ts
@@ -1,10 +1,49 @@
import axiosClient from '../axios/axios';
+const MIME_BY_EXTENSION: Record = {
+ 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 = {
getBlob: async (url: string): Promise => {
const response = await axiosClient.get(url, {
responseType: 'blob',
+ headers: {
+ Accept: '*/*',
+ },
+ transformRequest: [
+ (data, headers) => {
+ if (headers) {
+ delete headers['Content-Type'];
+ }
+ return data;
+ },
+ ],
});
- return response.data;
+
+ return normalizeMediaBlob(url, response.data);
},
};