diff --git a/package-lock.json b/package-lock.json index 5a70359..80618a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9480,6 +9480,21 @@ "optional": true } } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz", + "integrity": "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketOverviewCard.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketOverviewCard.tsx index 3d297a8..5ad1115 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/TicketOverviewCard.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/TicketOverviewCard.tsx @@ -149,8 +149,8 @@ export function TicketOverviewCard({ const seconds = Math.floor(ticket.video_metadata.duration_seconds % 60); const duration = `${minutes}:${seconds.toString().padStart(2, '0')}`; const chainageName = - ticket.location.chainage.name || ticket.chainage_name || null; - const packageName = ticket.location.package.name || null; + ticket.location?.chainage?.name || ticket.chainage_name || null; + const packageName = ticket.location?.package?.name || null; const locationLabel = [chainageName, packageName].filter(Boolean).join(', ') || '—'; diff --git a/src/app/(modules)/ticket/[ticketId]/components/TicketStatusActions.tsx b/src/app/(modules)/ticket/[ticketId]/components/TicketStatusActions.tsx index a7a2cba..f39a15c 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/TicketStatusActions.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/TicketStatusActions.tsx @@ -7,6 +7,7 @@ import type { TicketDetail } from '@/types'; import { AssignTicketAction } from './actions/AssignTicketAction'; import { ClosedTicketAction } from './actions/ClosedTicketAction'; import { NoTicketAction } from './actions/NoTicketAction'; +import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction'; import { ReviewRepairAction } from './actions/ReviewRepairAction'; import { SubmitRepairAction } from './actions/SubmitRepairAction'; @@ -29,10 +30,17 @@ function getTicketAction(ticket: TicketDetail) { if (ticket.assignment_status === 'assigned') { return ( } + permissions={PERMISSIONS.TICKET.ASSIGN} + fallback={ + } + > + + + } > - + ); } diff --git a/src/app/(modules)/ticket/[ticketId]/components/actions/RepairEvidenceForm.tsx b/src/app/(modules)/ticket/[ticketId]/components/actions/RepairEvidenceForm.tsx index 5c416da..f476e18 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/actions/RepairEvidenceForm.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/actions/RepairEvidenceForm.tsx @@ -1,7 +1,16 @@ 'use client'; import { useEffect, useRef, useState, type ReactNode } from 'react'; -import { ImageIcon, Loader2, Send, Video, X } from 'lucide-react'; +import { + Camera, + ImageIcon, + Loader2, + Play, + Plus, + Send, + Video, + X, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; @@ -10,122 +19,183 @@ import { cn } from '@/lib/utils'; import { useSubmitRepairUploadMutation } from '../../../hooks/useTicketQueries'; -const MAX_IMAGES = 5; +const MAX_IMAGES = 30; +const MAX_NOTES = 500; function RequiredMark() { return *; } -interface MediaDropzoneProps { +function formatVideoDuration(seconds: number) { + const totalSeconds = Math.floor(seconds); + const minutes = Math.floor(totalSeconds / 60); + const remainingSeconds = totalSeconds % 60; + return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`; +} + +interface MediaSectionHeaderProps { icon: ReactNode; - label: string; - accept: string; - file: File | null; - onFileChange: (file: File | null) => void; + title: string; + addLabel: string; + onAdd: () => void; disabled?: boolean; } -function MediaDropzone({ +function MediaSectionHeader({ icon, - label, - accept, - file, - onFileChange, + title, + addLabel, + onAdd, disabled = false, -}: MediaDropzoneProps) { - const inputRef = useRef(null); +}: MediaSectionHeaderProps) { + return ( +
+
+ {icon} + {title} +
+ +
+ ); +} +function MediaHint({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +interface EmptyMediaPlaceholderProps { + icon: ReactNode; + title: string; + description: string; + className?: string; + disabled?: boolean; + onClick: () => void; +} + +function EmptyMediaPlaceholder({ + icon, + title, + description, + className, + disabled = false, + onClick, +}: EmptyMediaPlaceholderProps) { return ( ); } -interface ImageSlotProps { - file: File | null; - previewUrl: string | null; +interface ImageThumbnailProps { + file: File; + previewUrl: string; disabled?: boolean; - onAdd: () => void; onRemove: () => void; } -function ImageSlot({ +function ImageThumbnail({ file, previewUrl, disabled = false, - onAdd, onRemove, -}: ImageSlotProps) { - if (file && previewUrl) { - return ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {file.name} - -
- ); - } - +}: ImageThumbnailProps) { return ( - +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {file.name} + +
+ ); +} + +interface VideoThumbnailProps { + file: File; + previewUrl: string; + duration: string | null; + disabled?: boolean; + onRemove: () => void; +} + +function VideoThumbnail({ + file, + previewUrl, + duration, + disabled = false, + onRemove, +}: VideoThumbnailProps) { + return ( +
+
); } @@ -139,14 +209,18 @@ export function RepairEvidenceForm({ defectClass, }: RepairEvidenceFormProps) { const imageInputRef = useRef(null); + const videoInputRef = useRef(null); const [notes, setNotes] = useState(''); const [videoFile, setVideoFile] = useState(null); + const [videoPreview, setVideoPreview] = useState(null); + const [videoDuration, setVideoDuration] = useState(null); const [imageFiles, setImageFiles] = useState([]); const [imagePreviews, setImagePreviews] = useState([]); const [error, setError] = useState(null); const submitRepairUploadMutation = useSubmitRepairUploadMutation(ticketId); const isSubmitting = submitRepairUploadMutation.isPending; const canSubmit = Boolean(notes.trim() && videoFile); + const canAddMoreImages = imageFiles.length < MAX_IMAGES; useEffect(() => { const urls = imageFiles.map((file) => URL.createObjectURL(file)); @@ -156,6 +230,32 @@ export function RepairEvidenceForm({ }; }, [imageFiles]); + useEffect(() => { + if (!videoFile) { + setVideoPreview(null); + setVideoDuration(null); + return; + } + + const url = URL.createObjectURL(videoFile); + setVideoPreview(url); + setVideoDuration(null); + + let cancelled = false; + const video = document.createElement('video'); + video.preload = 'metadata'; + video.onloadedmetadata = () => { + if (cancelled || !Number.isFinite(video.duration)) return; + setVideoDuration(formatVideoDuration(video.duration)); + }; + video.src = url; + + return () => { + cancelled = true; + URL.revokeObjectURL(url); + }; + }, [videoFile]); + const addImages = (files: FileList | null) => { if (!files?.length) return; @@ -177,6 +277,16 @@ export function RepairEvidenceForm({ ); }; + const selectVideo = (files: FileList | null) => { + setVideoFile(files?.[0] ?? null); + setError(null); + }; + + const removeVideo = () => { + setVideoFile(null); + setError(null); + }; + const removeImage = (index: number) => { setImageFiles((current) => current.filter((_, itemIndex) => itemIndex !== index), @@ -186,7 +296,7 @@ export function RepairEvidenceForm({ const handleSubmit = async () => { if (!notes.trim() || !videoFile) { - setError('Repair notes and video are required.'); + setError('Repair notes and a video are required.'); return; } @@ -206,81 +316,143 @@ export function RepairEvidenceForm({ } }; - const imageSlots = Array.from({ length: MAX_IMAGES }, (_, index) => ({ - file: imageFiles[index] ?? null, - previewUrl: imagePreviews[index] ?? null, - })); + const openImagePicker = () => { + if (!isSubmitting && canAddMoreImages) { + imageInputRef.current?.click(); + } + }; + + const openVideoPicker = () => { + if (!isSubmitting) { + videoInputRef.current?.click(); + } + }; return (
+
+ +
+

Instructions

+

+ Upload clear photos and one video of the repaired area and provide + relevant details. +

+
+
+ + { + addImages(event.target.files); + event.target.value = ''; + }} + /> + { + selectVideo(event.target.files); + event.target.value = ''; + }} + /> + +
+ } + title="Photos" + addLabel="Add Photos" + onAdd={openImagePicker} + disabled={isSubmitting || !canAddMoreImages} + /> + +
+ {imageFiles.length === 0 ? ( + } + title="No photos added yet" + description="Tap to upload repair photos" + disabled={isSubmitting} + onClick={openImagePicker} + className="min-h-[104px]" + /> + ) : ( +
+ {imageFiles.map((file, index) => ( + removeImage(index)} + /> + ))} +
+ )} +
+ + + You can upload up to {MAX_IMAGES} images (JPG, PNG) + +
+ +
+
+
+ +
+ {!videoFile || !videoPreview ? ( + } + title="No video added yet" + description="Tap to upload a repair video" + disabled={isSubmitting} + onClick={openVideoPicker} + className="min-h-[104px]" + /> + ) : ( + + )} +
+ + You can upload one video (MP4, MOV, AVI) +
+
-