/** * DealerCompletionDocumentsModal Component * Modal for Step 5: Activity Completion Documents * Allows dealers to upload completion documents, photos, expenses, and provide completion details */ import { useState, useRef, useMemo, useEffect } from 'react'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; import { CustomDatePicker } from '@/components/ui/date-picker'; import { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert, CheckCircle2, Eye, Download, IndianRupee } from 'lucide-react'; import { toast } from 'sonner'; import { handleSecurityError } from '@/utils/securityToast'; import '@/components/common/FilePreview/FilePreview.css'; import './DealerCompletionDocumentsModal.css'; import { validateHSNSAC } from '@/utils/validationUtils'; import { getStateCodeFromGSTIN, getActiveTaxComponents } from '@/utils/gstUtils'; interface ExpenseItem { id: string; description: string; amount: number; gstRate: number; gstAmt: number; quantity: number; hsnCode: string; cgstRate: number; cgstAmt: number; sgstRate: number; sgstAmt: number; igstRate: number; igstAmt: number; utgstRate: number; utgstAmt: number; cessRate: number; cessAmt: number; totalAmt: number; isService: boolean; } interface DealerCompletionDocumentsModalProps { isOpen: boolean; onClose: () => void; onSubmit: (data: { activityCompletionDate: string; numberOfParticipants?: number; closedExpenses: ExpenseItem[]; totalClosedExpenses: number; completionDocuments: File[]; activityPhotos: File[]; invoicesReceipts?: File[]; attendanceSheet?: File; completionDescription: string; }) => Promise; dealerName?: string; dealerGSTIN?: string; activityName?: string; requestId?: string; defaultGstRate?: number; documentPolicy: { maxFileSizeMB: number; allowedFileTypes: string[]; }; taxationType?: string | null; } export function DealerCompletionDocumentsModal({ isOpen, onClose, onSubmit, dealerName = 'Jaipur Royal Enfield', dealerGSTIN, activityName = 'Activity', requestId: _requestId, defaultGstRate = 18, documentPolicy, taxationType, }: DealerCompletionDocumentsModalProps) { const [activityCompletionDate, setActivityCompletionDate] = useState(''); const [numberOfParticipants, setNumberOfParticipants] = useState(''); // Determine active tax components based on dealer GSTIN const taxConfig = useMemo(() => { const stateCode = getStateCodeFromGSTIN(dealerGSTIN); return getActiveTaxComponents(stateCode); }, [dealerGSTIN]); const isNonGst = useMemo(() => { return taxationType === 'Non GST' || taxationType === 'Non-GST'; }, [taxationType]); const [expenseItems, setExpenseItems] = useState([]); const [completionDocuments, setCompletionDocuments] = useState([]); const [activityPhotos, setActivityPhotos] = useState([]); const [invoicesReceipts, setInvoicesReceipts] = useState([]); const [attendanceSheet, setAttendanceSheet] = useState(null); const [completionDescription, setCompletionDescription] = useState(''); const [submitting, setSubmitting] = useState(false); const [previewFile, setPreviewFile] = useState<{ file: File; url: string } | null>(null); const completionDocsInputRef = useRef(null); const photosInputRef = useRef(null); const invoicesInputRef = useRef(null); const attendanceInputRef = useRef(null); // Helper function to check if file can be previewed const canPreviewFile = (file: File): boolean => { const type = file.type.toLowerCase(); const name = file.name.toLowerCase(); return type.includes('image') || type.includes('pdf') || name.endsWith('.pdf') || name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') || name.endsWith('.gif') || name.endsWith('.webp'); }; // Cleanup object URLs when component unmounts or file changes useEffect(() => { return () => { if (previewFile?.url) { URL.revokeObjectURL(previewFile.url); } }; }, [previewFile]); // Initialize with one empty row if none exist useEffect(() => { if (expenseItems.length === 0) { setExpenseItems([{ id: '1', description: '', amount: 0, gstRate: defaultGstRate || 0, gstAmt: 0, quantity: 1, hsnCode: '', isService: false, cgstRate: 0, cgstAmt: 0, sgstRate: 0, sgstAmt: 0, igstRate: 0, igstAmt: 0, utgstRate: 0, utgstAmt: 0, cessRate: 0, cessAmt: 0, totalAmt: 0 }]); } }, [defaultGstRate]); // Handle file preview const handlePreviewFile = (file: File) => { if (!canPreviewFile(file)) { toast.error('Preview is only available for images and PDF files'); return; } // Cleanup previous preview URL if (previewFile?.url) { URL.revokeObjectURL(previewFile.url); } const url = URL.createObjectURL(file); setPreviewFile({ file, url }); }; // Handle download file (for non-previewable files) const handleDownloadFile = (file: File) => { const url = URL.createObjectURL(file); const a = document.createElement('a'); a.href = url; a.download = file.name; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; // Calculate total closed expenses (inclusive of GST) const totalClosedExpenses = useMemo(() => { return expenseItems.reduce((sum, item) => sum + (item.totalAmt || item.amount || 0), 0); }, [expenseItems]); // GST Calculation Helper const calculateGST = (amount: number, rates: { cgstRate: number; sgstRate: number; igstRate: number; utgstRate: number }, quantity: number = 1) => { const baseTotal = amount * quantity; const cgstAmt = (baseTotal * (rates.cgstRate || 0)) / 100; const sgstAmt = (baseTotal * (rates.sgstRate || 0)) / 100; const utgstAmt = (baseTotal * (rates.utgstRate || 0)) / 100; const igstAmt = (baseTotal * (rates.igstRate || 0)) / 100; const gstAmt = cgstAmt + sgstAmt + utgstAmt + igstAmt; const totalAmt = baseTotal + gstAmt; return { cgstRate: rates.cgstRate, cgstAmt, sgstRate: rates.sgstRate, sgstAmt, utgstRate: rates.utgstRate, utgstAmt, igstRate: rates.igstRate, igstAmt, gstAmt, gstRate: (rates.cgstRate || 0) + (rates.sgstRate || 0) + (rates.utgstRate || 0) + (rates.igstRate || 0), totalAmt }; }; // Check if all required fields are filled const isFormValid = useMemo(() => { const hasCompletionDate = activityCompletionDate !== ''; const hasDocuments = completionDocuments.length > 0; const hasPhotos = activityPhotos.length > 0; const hasDescription = completionDescription.trim().length > 0; const hasValidExpenseItems = expenseItems.length > 0 && expenseItems.every(item => item.description.trim() !== '' && item.amount > 0); const hasHSNSACErrors = isNonGst ? false : expenseItems.some(item => { const { isValid } = validateHSNSAC(item.hsnCode, item.isService); return !isValid; }); return hasCompletionDate && hasDocuments && hasPhotos && hasDescription && hasValidExpenseItems && !hasHSNSACErrors; }, [activityCompletionDate, completionDocuments, activityPhotos, completionDescription, isNonGst, expenseItems]); // Get today's date in YYYY-MM-DD format for max date const maxDate = new Date().toISOString().split('T')[0]; const handleAddExpense = () => { setExpenseItems([ ...expenseItems, { id: Date.now().toString(), description: '', amount: 0, gstRate: defaultGstRate || 0, gstAmt: 0, quantity: 1, hsnCode: '', isService: false, cgstRate: 0, cgstAmt: 0, sgstRate: 0, sgstAmt: 0, igstRate: 0, igstAmt: 0, utgstRate: 0, utgstAmt: 0, cessRate: 0, cessAmt: 0, totalAmt: 0 }, ]); }; const handleExpenseChange = (id: string, field: string, value: any) => { setExpenseItems( expenseItems.map((item) => { if (item.id === id) { let updatedItem = { ...item, [field]: value }; if (['amount', 'gstRate', 'cgstRate', 'sgstRate', 'utgstRate', 'igstRate', 'quantity'].includes(field)) { // Negative amount validation const numValue = parseFloat(value); if (!isNaN(numValue) && numValue < 0) { toast.error('Value cannot be negative'); return item; } const amount = field === 'amount' ? parseFloat(value) || 0 : item.amount; const quantity = field === 'quantity' ? parseInt(value) || 1 : item.quantity; let cgstRate = item.cgstRate; let sgstRate = item.sgstRate; let utgstRate = item.utgstRate; let igstRate = item.igstRate; if (field === 'cgstRate') { if (!taxConfig.isCGST) return item; cgstRate = parseFloat(value) || 0; // If UTGST is active for this dealer, sync with it if (taxConfig.isUTGST) { utgstRate = cgstRate; sgstRate = 0; } else { sgstRate = cgstRate; utgstRate = 0; } igstRate = 0; } else if (field === 'sgstRate') { if (!taxConfig.isSGST) return item; sgstRate = parseFloat(value) || 0; cgstRate = sgstRate; utgstRate = 0; igstRate = 0; } else if (field === 'utgstRate') { if (!taxConfig.isUTGST) return item; utgstRate = parseFloat(value) || 0; cgstRate = utgstRate; sgstRate = 0; igstRate = 0; } else if (field === 'igstRate') { if (!taxConfig.isIGST) return item; igstRate = parseFloat(value) || 0; cgstRate = 0; sgstRate = 0; utgstRate = 0; } else if (field === 'gstRate') { const totalRate = parseFloat(value) || 0; if (taxConfig.isIGST) { igstRate = totalRate; cgstRate = 0; sgstRate = 0; utgstRate = 0; } else { cgstRate = totalRate / 2; if (taxConfig.isUTGST) { utgstRate = totalRate / 2; sgstRate = 0; } else { sgstRate = totalRate / 2; utgstRate = 0; } igstRate = 0; } } const calculation = calculateGST(amount, { cgstRate, sgstRate, igstRate, utgstRate }, quantity); return { ...updatedItem, amount, quantity, ...calculation }; } return updatedItem; } return item; }) ); }; const handleRemoveExpense = (id: string) => { setExpenseItems(expenseItems.filter((item) => item.id !== id)); }; const handleCompletionDocsChange = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (files.length > 0) { const validFiles: File[] = []; const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024; const allowedExts = ['.pdf', '.doc', '.docx', '.zip', '.rar']; files.forEach(file => { const fileExt = '.' + file.name.split('.').pop()?.toLowerCase(); const simpleExt = file.name.split('.').pop()?.toLowerCase() || ''; // 1. Check file size if (file.size > maxSizeBytes) { toast.error(`"${file.name}" exceeds ${documentPolicy.maxFileSizeMB}MB limit and was not added.`); return; } // 2. Check field-specific types if (!allowedExts.includes(fileExt)) { toast.error(`"${file.name}" is not a supported document type (PDF, DOC, ZIP).`); return; } // 3. Check system policy types if (!documentPolicy.allowedFileTypes.includes(simpleExt)) { toast.error(`"${file.name}" has an unallowed file type according to system policy.`); return; } validFiles.push(file); }); if (validFiles.length > 0) { setCompletionDocuments([...completionDocuments, ...validFiles]); } if (completionDocsInputRef.current) completionDocsInputRef.current.value = ''; } }; const handleRemoveCompletionDoc = (index: number) => { setCompletionDocuments(completionDocuments.filter((_, i) => i !== index)); }; const handlePhotosChange = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (files.length > 0) { const validFiles: File[] = []; const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024; files.forEach(file => { const simpleExt = file.name.split('.').pop()?.toLowerCase() || ''; // 1. Check file size if (file.size > maxSizeBytes) { toast.error(`Photo "${file.name}" exceeds ${documentPolicy.maxFileSizeMB}MB limit.`); return; } // 2. Check field-specific (Image) if (!file.type.startsWith('image/')) { toast.error(`"${file.name}" is not an image file.`); return; } // 3. Check system policy if (!documentPolicy.allowedFileTypes.includes(simpleExt)) { toast.error(`"${file.name}" has an unsupported image format.`); return; } validFiles.push(file); }); if (validFiles.length > 0) { setActivityPhotos([...activityPhotos, ...validFiles]); } if (photosInputRef.current) photosInputRef.current.value = ''; } }; const handleRemovePhoto = (index: number) => { setActivityPhotos(activityPhotos.filter((_, i) => i !== index)); }; const handleInvoicesChange = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (files.length > 0) { const validFiles: File[] = []; const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024; const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png']; files.forEach(file => { const fileExt = '.' + file.name.split('.').pop()?.toLowerCase(); const simpleExt = file.name.split('.').pop()?.toLowerCase() || ''; // 1. Check file size if (file.size > maxSizeBytes) { toast.error(`Invoice "${file.name}" exceeds ${documentPolicy.maxFileSizeMB}MB limit.`); return; } // 2. Check field-specific if (!allowedExts.includes(fileExt)) { toast.error(`"${file.name}" is not a supported type (PDF, JPG, PNG).`); return; } // 3. Check system policy if (!documentPolicy.allowedFileTypes.includes(simpleExt)) { toast.error(`"${file.name}" format is not allowed by system policy.`); return; } validFiles.push(file); }); if (validFiles.length > 0) { setInvoicesReceipts([...invoicesReceipts, ...validFiles]); } if (invoicesInputRef.current) invoicesInputRef.current.value = ''; } }; const handleRemoveInvoice = (index: number) => { setInvoicesReceipts(invoicesReceipts.filter((_, i) => i !== index)); }; const handleAttendanceChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024; const allowedExts = ['.pdf', '.xlsx', '.xls', '.csv']; const fileExt = '.' + file.name.split('.').pop()?.toLowerCase(); const simpleExt = file.name.split('.').pop()?.toLowerCase() || ''; // 1. Check file size if (file.size > maxSizeBytes) { toast.error(`Attendance file exceeds ${documentPolicy.maxFileSizeMB}MB limit.`); if (attendanceInputRef.current) attendanceInputRef.current.value = ''; return; } // 2. Check field-specific if (!allowedExts.includes(fileExt)) { toast.error('Please upload PDF, Excel, or CSV files only'); if (attendanceInputRef.current) attendanceInputRef.current.value = ''; return; } // 3. Check system policy if (!documentPolicy.allowedFileTypes.includes(simpleExt)) { toast.error(`"${file.name}" format is not allowed by system policy.`); if (attendanceInputRef.current) attendanceInputRef.current.value = ''; return; } setAttendanceSheet(file); } }; const handleSubmit = async () => { if (!isFormValid) { toast.error('Please fill all required fields'); return; } // Check for negative amounts const hasNegativeAmounts = expenseItems.some(item => item.amount < 0 || item.quantity < 1); if (hasNegativeAmounts) { toast.error('Please ensure all amounts are non-negative and quantity is at least 1'); return; } // Filter valid expense items const validExpenses = expenseItems.filter( (item) => item.description.trim() !== '' && item.amount > 0 ); // Validation: Alert for 0% GST on taxable items (Skip for Non-GST) const hasZeroGstItems = !isNonGst && validExpenses.some(item => item.description.trim() !== '' && item.amount > 0 && (item.gstRate === 0 || !item.gstRate) ); if (hasZeroGstItems) { const confirmZeroGst = window.confirm( "One or more expenses have 0% GST. Are you sure you want to proceed? \n\nNote: If these items are taxable, please provide a valid GST rate to ensure correct E-Invoice generation." ); if (!confirmZeroGst) { setSubmitting(false); return; } } try { setSubmitting(true); await onSubmit({ activityCompletionDate, numberOfParticipants: numberOfParticipants ? parseInt(numberOfParticipants) : undefined, closedExpenses: validExpenses, totalClosedExpenses, completionDocuments, activityPhotos, invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined, attendanceSheet: attendanceSheet || undefined, completionDescription, }); handleReset(); onClose(); } catch (error) { console.error('Failed to submit completion documents:', error); if (!handleSecurityError(error)) { toast.error('Failed to submit completion documents. Please try again.'); } } finally { setSubmitting(false); } }; const handleReset = () => { // Cleanup preview URL if exists if (previewFile?.url) { URL.revokeObjectURL(previewFile.url); } setPreviewFile(null); setActivityCompletionDate(''); setNumberOfParticipants(''); setExpenseItems([{ id: '1', description: '', amount: 0, gstRate: defaultGstRate || 0, gstAmt: 0, quantity: 1, hsnCode: '', isService: false, cgstRate: 0, cgstAmt: 0, sgstRate: 0, sgstAmt: 0, igstRate: 0, igstAmt: 0, utgstRate: 0, utgstAmt: 0, cessRate: 0, cessAmt: 0, totalAmt: 0 }]); setCompletionDocuments([]); setActivityPhotos([]); setInvoicesReceipts([]); setAttendanceSheet(null); setCompletionDescription(''); if (completionDocsInputRef.current) completionDocsInputRef.current.value = ''; if (photosInputRef.current) photosInputRef.current.value = ''; if (invoicesInputRef.current) invoicesInputRef.current.value = ''; if (attendanceInputRef.current) attendanceInputRef.current.value = ''; }; const handleClose = () => { if (!submitting) { handleReset(); onClose(); } }; return ( <>
Activity Completion Documents
{taxationType && ( {taxationType === 'GST' ? 'GST Claim' : 'Non-GST Claim'} )}
Step 5: Upload completion proof and final documents
Dealer: {dealerName}
Activity: {activityName}
Please upload completion documents, photos, and provide details about the completed activity.
{/* Activity Completion Date */}
setActivityCompletionDate(date || '')} maxDate={maxDate} placeholderText="dd/mm/yyyy" className="w-full max-w-[280px]" wrapperClassName="max-w-[280px]" />
{/* Closed Expenses Section */}

Closed Expenses

{!isNonGst && (
Tax fields are automatically toggled based on the dealer's state (Inter-state vs Intra-state).
)}
{expenseItems.map((item) => (
handleExpenseChange(item.id, 'description', e.target.value) } className="w-full bg-white text-sm" />
{isNonGst && (
handleExpenseChange(item.id, 'amount', e.target.value) } className="w-full bg-white text-sm pl-8" />
)} {!isNonGst && (
handleExpenseChange(item.id, 'amount', e.target.value) } className="w-full bg-white text-sm pl-8" />
)} {!isNonGst && ( <>
handleExpenseChange(item.id, 'hsnCode', e.target.value) } className={`w-full bg-white text-sm ${!validateHSNSAC(item.hsnCode, item.isService).isValid ? 'border-red-500 focus-visible:ring-red-500' : ''}`} /> {!validateHSNSAC(item.hsnCode, item.isService).isValid && ( {validateHSNSAC(item.hsnCode, item.isService).message} )}
handleExpenseChange(item.id, 'cgstRate', e.target.value) } disabled={!taxConfig.isCGST} className="w-full bg-white text-xs px-1 text-center disabled:bg-gray-100 disabled:text-gray-400" />
handleExpenseChange(item.id, 'sgstRate', e.target.value) } disabled={!taxConfig.isSGST} className="w-full bg-white text-xs px-1 text-center disabled:bg-gray-100 disabled:text-gray-400" />
handleExpenseChange(item.id, 'utgstRate', e.target.value) } disabled={!taxConfig.isUTGST} className="w-full bg-white text-xs px-1 text-center disabled:bg-gray-100 disabled:text-gray-400" />
handleExpenseChange(item.id, 'igstRate', e.target.value) } disabled={!taxConfig.isIGST} className="w-full bg-white text-xs px-1 text-center disabled:bg-gray-100 disabled:text-gray-400" />
)}
{!isNonGst ? ( <>
CGST: ₹{(item.cgstAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })} {item.sgstAmt > 0 && SGST: ₹{(item.sgstAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })}} {item.utgstAmt > 0 && UTGST: ₹{(item.utgstAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })}} IGST: ₹{(item.igstAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })}
GST Total: ₹{(item.gstAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })} Item Total: ₹{(item.totalAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })}
) : (
)}
Item Total ₹{(item.amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}
))}
{expenseItems.length === 0 && (

No expenses added. Click "Add Expense" to add expense items.

)} {expenseItems.length > 0 && totalClosedExpenses > 0 && (
Total Closed Expenses: ₹{totalClosedExpenses.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
)}
{/* Completion Evidence Section */}

Completion Evidence

Required
{/* Grid layout for Completion Documents and Activity Photos */}
{/* Completion Documents */}

Upload documents proving activity completion (reports, certificates, etc.) - Can upload multiple files or ZIP folder

0 ? 'border-green-500 bg-green-50 hover:border-green-600' : 'border-gray-300 hover:border-blue-500 bg-white' }`} > documentPolicy.allowedFileTypes.includes(ext.replace('.', ''))).join(',')} className="hidden" id="completionDocs" onChange={handleCompletionDocsChange} />
{completionDocuments.length > 0 && (

Selected Documents ({completionDocuments.length}):

{completionDocuments.map((file, index) => (
{file.name}
{canPreviewFile(file) && ( )}
))}
)}
{/* Activity Photos */}

Upload photos from the completed activity (event photos, installations, etc.)

0 ? 'border-green-500 bg-green-50 hover:border-green-600' : 'border-gray-300 hover:border-blue-500 bg-white' }`} > documentPolicy.allowedFileTypes.includes(ext.replace('.', ''))).join(',')} className="hidden" id="completionPhotos" onChange={handlePhotosChange} />
{activityPhotos.length > 0 && (

Selected Photos ({activityPhotos.length}):

{activityPhotos.map((file, index) => (
{file.name}
{canPreviewFile(file) && ( )}
))}
)}
{/* Supporting Documents Section */}

Supporting Documents

Optional
{/* Grid layout for Invoices/Receipts and Attendance Sheet */}
{/* Invoices/Receipts */}

Upload invoices and receipts for expenses incurred

0 ? 'border-blue-500 bg-blue-50 hover:border-blue-600' : 'border-gray-300 hover:border-blue-500 bg-white' }`} > documentPolicy.allowedFileTypes.includes(ext.replace('.', ''))).join(',')} className="hidden" id="invoiceReceipts" onChange={handleInvoicesChange} />
{invoicesReceipts.length > 0 && (

Selected Documents ({invoicesReceipts.length}):

{invoicesReceipts.map((file, index) => (
{file.name}
{canPreviewFile(file) && ( )}
))}
)}
{/* Attendance Sheet */}

Upload attendance records or participant lists (if applicable)

documentPolicy.allowedFileTypes.includes(ext.replace('.', ''))).join(',')} className="hidden" id="attendanceDoc" onChange={handleAttendanceChange} />
{attendanceSheet && (

Selected Document:

{attendanceSheet.name}
{canPreviewFile(attendanceSheet) && ( )}
)}
{/* Completion Description */}