224 lines
8.9 KiB
TypeScript
224 lines
8.9 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState } from 'react';
|
|
import { Upload, Video, Image as ImageIcon, X, Loader2, CheckCircle2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { adminService } from '@/services/api/adminService';
|
|
|
|
interface MediaProofModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
jobId: string;
|
|
targetStatus: 'INSPECTION_COMPLETED' | 'READY_FOR_DELIVERY';
|
|
onSuccess: () => void;
|
|
}
|
|
|
|
export function MediaProofModal({
|
|
open,
|
|
onClose,
|
|
jobId,
|
|
targetStatus,
|
|
onSuccess,
|
|
}: MediaProofModalProps) {
|
|
const [files, setFiles] = useState<File[]>([]);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadProgress, setUploadProgress] = useState<string>('');
|
|
|
|
if (!open) return null;
|
|
|
|
const isInspection = targetStatus === 'INSPECTION_COMPLETED';
|
|
const title = isInspection ? 'Inspection Done Proof Required' : 'Ready for Delivery Proof Required';
|
|
const category = isInspection ? 'INSPECTION_DONE' : 'READY_FOR_DELIVERY';
|
|
const subtitle = isInspection
|
|
? 'Upload 1 short video (max 30 seconds) OR up to 4 photos of the inspected product condition before proceeding.'
|
|
: 'Upload video or photo proof of the completed repair showing the device fully functional and ready for delivery.';
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (!e.target.files || e.target.files.length === 0) return;
|
|
const selectedList = Array.from(e.target.files);
|
|
|
|
const hasVideo = selectedList.some((f) => f.type.startsWith('video/'));
|
|
const images = selectedList.filter((f) => f.type.startsWith('image/'));
|
|
|
|
if (hasVideo && selectedList.length > 1) {
|
|
toast.error('Please upload either 1 video OR photos (not combined).');
|
|
return;
|
|
}
|
|
|
|
if (hasVideo) {
|
|
const videoFile = selectedList.find((f) => f.type.startsWith('video/'))!;
|
|
const videoEl = document.createElement('video');
|
|
videoEl.preload = 'metadata';
|
|
videoEl.onloadedmetadata = () => {
|
|
window.URL.revokeObjectURL(videoEl.src);
|
|
if (videoEl.duration > 30.5) {
|
|
toast.error(`Video length is ${Math.round(videoEl.duration)}s. Maximum allowed video length is 30 seconds.`);
|
|
} else {
|
|
setFiles([videoFile]);
|
|
toast.success(`Video verified (${Math.round(videoEl.duration)}s). Ready to upload.`);
|
|
}
|
|
};
|
|
videoEl.onerror = () => {
|
|
toast.error('Failed to parse video file duration.');
|
|
};
|
|
videoEl.src = URL.createObjectURL(videoFile);
|
|
return;
|
|
}
|
|
|
|
if (images.length > 4) {
|
|
toast.error('Maximum 4 photos allowed.');
|
|
setFiles(images.slice(0, 4));
|
|
} else {
|
|
setFiles(images);
|
|
}
|
|
};
|
|
|
|
const removeFile = (index: number) => {
|
|
setFiles((prev) => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (files.length === 0) {
|
|
toast.warning('Please select at least 1 video or photo proof to continue.');
|
|
return;
|
|
}
|
|
|
|
setUploading(true);
|
|
setUploadProgress('Starting upload...');
|
|
try {
|
|
const fileIds: string[] = [];
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
const fileSizeMb = (file.size / (1024 * 1024)).toFixed(1);
|
|
setUploadProgress(`Uploading file ${i + 1} of ${files.length} (${fileSizeMb} MB)...`);
|
|
const res = await adminService.uploadMediaFile(file);
|
|
if (res.file_id) {
|
|
fileIds.push(res.file_id);
|
|
}
|
|
}
|
|
|
|
if (fileIds.length === 0) {
|
|
throw new Error('Media file upload failed.');
|
|
}
|
|
|
|
setUploadProgress('Saving job status & linking proof...');
|
|
await adminService.attachJobMedia(jobId, category, fileIds);
|
|
await adminService.updateServiceJobStatus(jobId, targetStatus);
|
|
|
|
toast.success(`${isInspection ? 'Inspection proof' : 'Ready for delivery proof'} uploaded successfully.`);
|
|
onSuccess();
|
|
onClose();
|
|
} catch (err: any) {
|
|
toast.error(err.message || 'Failed to attach media proof.');
|
|
} finally {
|
|
setUploading(false);
|
|
setUploadProgress('');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
|
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-2xl max-w-lg w-full p-6 border border-slate-200 dark:border-slate-800 animate-in fade-in zoom-in-95 duration-200">
|
|
<div className="flex items-start justify-between border-b border-slate-100 dark:border-slate-800 pb-4">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="p-2 rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400">
|
|
{isInspection ? <Video className="w-5 h-5" /> : <CheckCircle2 className="w-5 h-5" />}
|
|
</span>
|
|
<h3 className="text-lg font-bold text-slate-900 dark:text-white">{title}</h3>
|
|
</div>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">{subtitle}</p>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
disabled={uploading}
|
|
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 p-1 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="my-6 space-y-4">
|
|
<label className="border-2 border-dashed border-slate-300 dark:border-slate-700 hover:border-blue-500 dark:hover:border-blue-400 rounded-xl p-6 flex flex-col items-center justify-center cursor-pointer bg-slate-50/50 dark:bg-slate-800/50 hover:bg-blue-50/30 dark:hover:bg-blue-950/20 transition group">
|
|
<Upload className="w-8 h-8 text-slate-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 mb-2 transition-transform group-hover:-translate-y-1" />
|
|
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300">
|
|
Click to select 30s Video or Photos
|
|
</span>
|
|
<span className="text-xs text-slate-400 mt-1">
|
|
Supports MP4, WEBM (Max 30s) or JPG, PNG (Max 4 photos)
|
|
</span>
|
|
<input
|
|
type="file"
|
|
accept="video/*,image/*"
|
|
multiple
|
|
onChange={handleFileChange}
|
|
disabled={uploading}
|
|
className="hidden"
|
|
/>
|
|
</label>
|
|
|
|
{files.length > 0 && (
|
|
<div className="space-y-2">
|
|
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
|
Selected Proof Files ({files.length})
|
|
</span>
|
|
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto pr-1">
|
|
{files.map((file, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="flex items-center justify-between p-2 rounded-lg bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-xs"
|
|
>
|
|
<div className="flex items-center gap-2 truncate pr-2">
|
|
{file.type.startsWith('video/') ? (
|
|
<Video className="w-4 h-4 text-blue-500 shrink-0" />
|
|
) : (
|
|
<ImageIcon className="w-4 h-4 text-emerald-500 shrink-0" />
|
|
)}
|
|
<span className="truncate text-slate-700 dark:text-slate-300 font-medium">
|
|
{file.name}
|
|
</span>
|
|
</div>
|
|
{!uploading && (
|
|
<button
|
|
onClick={() => removeFile(idx)}
|
|
className="text-slate-400 hover:text-red-500 p-0.5 rounded transition"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 border-t border-slate-100 dark:border-slate-800 pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
disabled={uploading}
|
|
className="px-4 py-2 text-sm font-medium text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleSubmit}
|
|
disabled={uploading || files.length === 0}
|
|
className="px-5 py-2 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg shadow-sm flex items-center gap-2 transition"
|
|
>
|
|
{uploading ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
{uploadProgress || 'Uploading...'}
|
|
</>
|
|
) : (
|
|
'Confirm & Save Status'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|