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 ( - - - {`Repair - -

- {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} - - - -
-
- Technician - -
- -
- -
- Repair Summary - {repair.notes?.trim() ? ( -
-

- {repair.notes} -

-
- ) : ( - - )} -
-
-
- ); -} 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 */} + {`Repair + {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({ )} > {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); }, };