1487 lines
65 KiB
TypeScript
1487 lines
65 KiB
TypeScript
/**
|
|
* 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<void>;
|
|
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<ExpenseItem[]>([]);
|
|
const [completionDocuments, setCompletionDocuments] = useState<File[]>([]);
|
|
const [activityPhotos, setActivityPhotos] = useState<File[]>([]);
|
|
const [invoicesReceipts, setInvoicesReceipts] = useState<File[]>([]);
|
|
const [attendanceSheet, setAttendanceSheet] = useState<File | null>(null);
|
|
const [completionDescription, setCompletionDescription] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [previewFile, setPreviewFile] = useState<{ file: File; url: string } | null>(null);
|
|
|
|
const completionDocsInputRef = useRef<HTMLInputElement>(null);
|
|
const photosInputRef = useRef<HTMLInputElement>(null);
|
|
const invoicesInputRef = useRef<HTMLInputElement>(null);
|
|
const attendanceInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
|
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<HTMLInputElement>) => {
|
|
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<HTMLInputElement>) => {
|
|
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<HTMLInputElement>) => {
|
|
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 (
|
|
<>
|
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
<DialogContent className="dealer-completion-documents-modal overflow-hidden flex flex-col">
|
|
<DialogHeader className="px-6 pt-6 pb-3 flex-shrink-0">
|
|
<DialogTitle className="font-semibold flex items-center gap-2 text-xl sm:text-2xl flex-wrap">
|
|
<div className="flex items-center gap-2">
|
|
<Upload className="w-5 h-5 sm:w-6 sm:h-6 text-[--re-green]" />
|
|
Activity Completion Documents
|
|
</div>
|
|
{taxationType && (
|
|
<Badge className={`ml-2 border-none shadow-sm ${taxationType === 'GST' ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
|
|
{taxationType === 'GST' ? 'GST Claim' : 'Non-GST Claim'}
|
|
</Badge>
|
|
)}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-sm sm:text-base">
|
|
Step 5: Upload completion proof and final documents
|
|
</DialogDescription>
|
|
<div className="space-y-1 mt-2 text-xs sm:text-sm text-gray-600">
|
|
<div>
|
|
<strong>Dealer:</strong> {dealerName}
|
|
</div>
|
|
<div>
|
|
<strong>Activity:</strong> {activityName}
|
|
</div>
|
|
<div className="mt-2">
|
|
Please upload completion documents, photos, and provide details about the completed activity.
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-3">
|
|
<div className="space-y-5 sm:space-y-6">
|
|
{/* Activity Completion Date */}
|
|
<div className="space-y-1.5 sm:space-y-2">
|
|
<Label className="text-sm sm:text-base font-semibold flex items-center gap-2" htmlFor="completionDate">
|
|
<Calendar className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
Activity Completion Date *
|
|
</Label>
|
|
<CustomDatePicker
|
|
value={activityCompletionDate || null}
|
|
onChange={(date) => setActivityCompletionDate(date || '')}
|
|
maxDate={maxDate}
|
|
placeholderText="dd/mm/yyyy"
|
|
className="w-full max-w-[280px]"
|
|
wrapperClassName="max-w-[280px]"
|
|
/>
|
|
</div>
|
|
|
|
{/* Closed Expenses Section */}
|
|
<div className="space-y-3 sm:space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-base sm:text-lg">Closed Expenses</h3>
|
|
</div>
|
|
{!isNonGst && (
|
|
<div className="text-[10px] text-gray-500 italic mt-0.5">
|
|
Tax fields are automatically toggled based on the dealer's state (Inter-state vs Intra-state).
|
|
</div>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
onClick={handleAddExpense}
|
|
className="bg-[#2d4a3e] hover:bg-[#1f3329] text-white"
|
|
size="sm"
|
|
>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
Add Expense
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-3 sm:space-y-4 max-h-[400px] overflow-y-auto pr-1">
|
|
{expenseItems.map((item) => (
|
|
<div key={item.id} className="p-4 border rounded-lg bg-gray-50/50 space-y-4 relative group">
|
|
<div className="flex gap-3 items-start w-full">
|
|
<div className={`${isNonGst ? 'flex-[3]' : 'flex-1'} min-w-0`}>
|
|
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">Item description</Label>
|
|
<Input
|
|
placeholder="e.g., Venue rental, Refreshments"
|
|
value={item.description}
|
|
onChange={(e) =>
|
|
handleExpenseChange(item.id, 'description', e.target.value)
|
|
}
|
|
className="w-full bg-white text-sm"
|
|
/>
|
|
</div>
|
|
{isNonGst && (
|
|
<div className="w-28 sm:w-36 flex-shrink-0">
|
|
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">Amount</Label>
|
|
<div className="relative">
|
|
<IndianRupee className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
|
<Input
|
|
type="number"
|
|
placeholder="0.00"
|
|
min="0"
|
|
step="0.01"
|
|
value={item.amount || ''}
|
|
onChange={(e) =>
|
|
handleExpenseChange(item.id, 'amount', e.target.value)
|
|
}
|
|
className="w-full bg-white text-sm pl-8"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!isNonGst && (
|
|
<div className="w-28 sm:w-36 flex-shrink-0">
|
|
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">Amount (Base)</Label>
|
|
<div className="relative">
|
|
<IndianRupee className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
|
<Input
|
|
type="number"
|
|
placeholder="0.00"
|
|
min="0"
|
|
step="0.01"
|
|
value={item.amount || ''}
|
|
onChange={(e) =>
|
|
handleExpenseChange(item.id, 'amount', e.target.value)
|
|
}
|
|
className="w-full bg-white text-sm pl-8"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!isNonGst && (
|
|
<>
|
|
<div className="w-20 sm:w-24 flex-shrink-0">
|
|
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">HSN/SAC Code</Label>
|
|
<Input
|
|
placeholder="HSN/SAC Code"
|
|
value={item.hsnCode || ''}
|
|
onChange={(e) =>
|
|
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 && (
|
|
<span className="text-[9px] text-red-500 mt-1 block leading-tight">
|
|
{validateHSNSAC(item.hsnCode, item.isService).message}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="w-24 sm:w-28 flex-shrink-0">
|
|
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">Item Type</Label>
|
|
<select
|
|
value={item.isService ? 'SAC' : 'HSN'}
|
|
onChange={(e) =>
|
|
handleExpenseChange(item.id, 'isService', e.target.value === 'SAC')
|
|
}
|
|
className="flex h-9 w-full rounded-md border border-input bg-white px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<option value="HSN">HSN (Goods)</option>
|
|
<option value="SAC">SAC (Service)</option>
|
|
</select>
|
|
</div>
|
|
<div className="w-14 sm:w-16 flex-shrink-0">
|
|
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">CGST %</Label>
|
|
<Input
|
|
type="number"
|
|
placeholder="%"
|
|
min="0"
|
|
max="100"
|
|
step="0.1"
|
|
value={item.cgstRate || ''}
|
|
onChange={(e) =>
|
|
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"
|
|
/>
|
|
</div>
|
|
<div className="w-14 sm:w-16 flex-shrink-0">
|
|
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">SGST %</Label>
|
|
<Input
|
|
type="number"
|
|
placeholder="%"
|
|
min="0"
|
|
max="100"
|
|
step="0.1"
|
|
value={item.sgstRate || ''}
|
|
onChange={(e) =>
|
|
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"
|
|
/>
|
|
</div>
|
|
<div className="w-14 sm:w-16 flex-shrink-0">
|
|
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">UTGST %</Label>
|
|
<Input
|
|
type="number"
|
|
placeholder="%"
|
|
min="0"
|
|
max="100"
|
|
step="0.1"
|
|
value={item.utgstRate || ''}
|
|
onChange={(e) =>
|
|
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"
|
|
/>
|
|
</div>
|
|
<div className="w-14 sm:w-16 flex-shrink-0">
|
|
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">IGST %</Label>
|
|
<Input
|
|
type="number"
|
|
placeholder="%"
|
|
min="0"
|
|
max="100"
|
|
step="0.1"
|
|
value={item.igstRate || ''}
|
|
onChange={(e) =>
|
|
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"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="mt-5 hover:bg-red-100 hover:text-red-700 flex-shrink-0 h-9 w-9 p-0"
|
|
onClick={() => handleRemoveExpense(item.id)}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className={`grid grid-cols-2 sm:grid-cols-5 gap-3 pt-3 border-t border-dashed border-gray-200 ${isNonGst ? 'items-center' : ''}`}>
|
|
{!isNonGst ? (
|
|
<>
|
|
<div className="flex flex-wrap gap-4 text-gray-500 font-medium">
|
|
<span>CGST: <span className="text-gray-900 font-semibold">₹{(item.cgstAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })}</span></span>
|
|
{item.sgstAmt > 0 && <span>SGST: <span className="text-gray-900 font-semibold">₹{(item.sgstAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })}</span></span>}
|
|
{item.utgstAmt > 0 && <span>UTGST: <span className="text-gray-900 font-semibold">₹{(item.utgstAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })}</span></span>}
|
|
<span>IGST: <span className="text-gray-900 font-semibold">₹{(item.igstAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })}</span></span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-4 items-center sm:justify-end">
|
|
<span className="text-gray-500">GST Total: <span className="text-gray-900 font-bold">₹{(item.gstAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })}</span></span>
|
|
<Badge className="bg-[#2d4a3e] text-white px-3 py-1 text-xs">Item Total: ₹{(item.totalAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })}</Badge>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="col-span-4 invisible"></div>
|
|
)}
|
|
<div className="flex flex-col items-end">
|
|
<span className="text-[10px] text-gray-500 uppercase">Item Total</span>
|
|
<span className="text-sm font-bold text-[#2d4a3e]">₹{(item.amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{expenseItems.length === 0 && (
|
|
<p className="text-xs sm:text-sm text-gray-500 italic">
|
|
No expenses added. Click "Add Expense" to add expense items.
|
|
</p>
|
|
)}
|
|
{expenseItems.length > 0 && totalClosedExpenses > 0 && (
|
|
<div className="pt-2 border-t">
|
|
<div className="flex justify-between items-center">
|
|
<span className="font-semibold text-sm sm:text-base">Total Closed Expenses:</span>
|
|
<span className="font-semibold text-base sm:text-lg">
|
|
₹{totalClosedExpenses.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Completion Evidence Section */}
|
|
<div className="space-y-3 sm:space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-base sm:text-lg">Completion Evidence</h3>
|
|
<Badge variant="outline" className="text-xs border-red-500 text-red-700 bg-red-50 font-medium">Required</Badge>
|
|
</div>
|
|
|
|
{/* Grid layout for Completion Documents and Activity Photos */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
|
{/* Completion Documents */}
|
|
<div>
|
|
<Label className="text-sm sm:text-base font-semibold flex items-center gap-2">
|
|
<FileText className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
Completion Documents *
|
|
</Label>
|
|
<p className="text-xs sm:text-sm text-gray-600 mb-2">
|
|
Upload documents proving activity completion (reports, certificates, etc.) - Can upload multiple files or ZIP folder
|
|
</p>
|
|
<div
|
|
className={`border-2 border-dashed rounded-lg p-3 sm:p-4 transition-all duration-200 ${completionDocuments.length > 0
|
|
? 'border-green-500 bg-green-50 hover:border-green-600'
|
|
: 'border-gray-300 hover:border-blue-500 bg-white'
|
|
}`}
|
|
>
|
|
<input
|
|
ref={completionDocsInputRef}
|
|
type="file"
|
|
multiple
|
|
accept={['.pdf', '.doc', '.docx', '.zip', '.rar'].filter(ext => documentPolicy.allowedFileTypes.includes(ext.replace('.', ''))).join(',')}
|
|
className="hidden"
|
|
id="completionDocs"
|
|
onChange={handleCompletionDocsChange}
|
|
/>
|
|
<label
|
|
htmlFor="completionDocs"
|
|
className="cursor-pointer flex flex-col items-center gap-2"
|
|
>
|
|
{completionDocuments.length > 0 ? (
|
|
<>
|
|
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
|
<div className="flex flex-col items-center gap-1">
|
|
<span className="text-sm font-semibold text-green-700">
|
|
{completionDocuments.length} document{completionDocuments.length !== 1 ? 's' : ''} selected
|
|
</span>
|
|
<span className="text-xs text-green-600">
|
|
Click to add more documents
|
|
</span>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Upload className="w-8 h-8 text-gray-400" />
|
|
<span className="text-sm text-gray-600">
|
|
Click to upload documents (Max {documentPolicy.maxFileSizeMB}MB)
|
|
</span>
|
|
<p className="text-[10px] text-gray-400">PDF, DOC, ZIP allowed</p>
|
|
</>
|
|
)}
|
|
</label>
|
|
</div>
|
|
{completionDocuments.length > 0 && (
|
|
<div className="mt-3 space-y-2">
|
|
<p className="text-xs font-medium text-gray-600 mb-1">
|
|
Selected Documents ({completionDocuments.length}):
|
|
</p>
|
|
{completionDocuments.map((file, index) => (
|
|
<div
|
|
key={index}
|
|
className="flex items-start justify-between bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 p-2 sm:p-3 rounded-lg text-xs sm:text-sm shadow-sm hover:shadow-md transition-shadow w-full"
|
|
>
|
|
<div className="flex items-start gap-2 flex-1 min-w-0 pr-2">
|
|
<FileText className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
|
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
|
{canPreviewFile(file) && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700"
|
|
onClick={() => handlePreviewFile(file)}
|
|
title="Preview file"
|
|
>
|
|
<Eye className="w-3.5 h-3.5" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700"
|
|
onClick={() => handleDownloadFile(file)}
|
|
title="Download file"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700"
|
|
onClick={() => handleRemoveCompletionDoc(index)}
|
|
title="Remove document"
|
|
>
|
|
<X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Activity Photos */}
|
|
<div>
|
|
<Label className="text-sm sm:text-base font-semibold flex items-center gap-2">
|
|
<Image className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
Activity Photos *
|
|
</Label>
|
|
<p className="text-xs sm:text-sm text-gray-600 mb-2">
|
|
Upload photos from the completed activity (event photos, installations, etc.)
|
|
</p>
|
|
<div
|
|
className={`border-2 border-dashed rounded-lg p-3 sm:p-4 transition-all duration-200 ${activityPhotos.length > 0
|
|
? 'border-green-500 bg-green-50 hover:border-green-600'
|
|
: 'border-gray-300 hover:border-blue-500 bg-white'
|
|
}`}
|
|
>
|
|
<input
|
|
ref={photosInputRef}
|
|
type="file"
|
|
multiple
|
|
accept={['.jpg', '.jpeg', '.png', '.gif', '.webp'].filter(ext => documentPolicy.allowedFileTypes.includes(ext.replace('.', ''))).join(',')}
|
|
className="hidden"
|
|
id="completionPhotos"
|
|
onChange={handlePhotosChange}
|
|
/>
|
|
<label
|
|
htmlFor="completionPhotos"
|
|
className="cursor-pointer flex flex-col items-center gap-2"
|
|
>
|
|
{activityPhotos.length > 0 ? (
|
|
<>
|
|
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
|
<div className="flex flex-col items-center gap-1">
|
|
<span className="text-sm font-semibold text-green-700">
|
|
{activityPhotos.length} photo{activityPhotos.length !== 1 ? 's' : ''} selected
|
|
</span>
|
|
<span className="text-xs text-green-600">
|
|
Click to add more photos
|
|
</span>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Image className="w-8 h-8 text-gray-400" />
|
|
<span className="text-sm text-gray-600">
|
|
Click to upload photos (JPG, PNG - multiple files allowed)
|
|
</span>
|
|
</>
|
|
)}
|
|
</label>
|
|
</div>
|
|
{activityPhotos.length > 0 && (
|
|
<div className="mt-3 space-y-2">
|
|
<p className="text-xs font-medium text-gray-600 mb-1">
|
|
Selected Photos ({activityPhotos.length}):
|
|
</p>
|
|
{activityPhotos.map((file, index) => (
|
|
<div
|
|
key={index}
|
|
className="flex items-start justify-between bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 p-2 sm:p-3 rounded-lg text-xs sm:text-sm shadow-sm hover:shadow-md transition-shadow w-full"
|
|
>
|
|
<div className="flex items-start gap-2 flex-1 min-w-0 pr-2">
|
|
<Image className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
|
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
|
{canPreviewFile(file) && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700"
|
|
onClick={() => handlePreviewFile(file)}
|
|
title="Preview photo"
|
|
>
|
|
<Eye className="w-3.5 h-3.5" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700"
|
|
onClick={() => handleDownloadFile(file)}
|
|
title="Download photo"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700"
|
|
onClick={() => handleRemovePhoto(index)}
|
|
title="Remove photo"
|
|
>
|
|
<X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Supporting Documents Section */}
|
|
<div className="space-y-3 sm:space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-base sm:text-lg">Supporting Documents</h3>
|
|
<Badge className="bg-secondary text-secondary-foreground text-xs">Optional</Badge>
|
|
</div>
|
|
|
|
{/* Grid layout for Invoices/Receipts and Attendance Sheet */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
|
{/* Invoices/Receipts */}
|
|
<div>
|
|
<Label className="text-sm sm:text-base font-semibold flex items-center gap-2">
|
|
<Receipt className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
Invoices / Receipts
|
|
</Label>
|
|
<p className="text-xs sm:text-sm text-gray-600 mb-2">
|
|
Upload invoices and receipts for expenses incurred
|
|
</p>
|
|
<div
|
|
className={`border-2 border-dashed rounded-lg p-3 sm:p-4 transition-all duration-200 ${invoicesReceipts.length > 0
|
|
? 'border-blue-500 bg-blue-50 hover:border-blue-600'
|
|
: 'border-gray-300 hover:border-blue-500 bg-white'
|
|
}`}
|
|
>
|
|
<input
|
|
ref={invoicesInputRef}
|
|
type="file"
|
|
multiple
|
|
accept={['.pdf', '.jpg', '.jpeg', '.png'].filter(ext => documentPolicy.allowedFileTypes.includes(ext.replace('.', ''))).join(',')}
|
|
className="hidden"
|
|
id="invoiceReceipts"
|
|
onChange={handleInvoicesChange}
|
|
/>
|
|
<label
|
|
htmlFor="invoiceReceipts"
|
|
className="cursor-pointer flex flex-col items-center gap-2"
|
|
>
|
|
{invoicesReceipts.length > 0 ? (
|
|
<>
|
|
<CheckCircle2 className="w-8 h-8 text-blue-600" />
|
|
<div className="flex flex-col items-center gap-1">
|
|
<span className="text-sm font-semibold text-blue-700">
|
|
{invoicesReceipts.length} document{invoicesReceipts.length !== 1 ? 's' : ''} selected
|
|
</span>
|
|
<span className="text-xs text-blue-600">
|
|
Click to add more documents
|
|
</span>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Receipt className="w-8 h-8 text-gray-400" />
|
|
<span className="text-sm text-gray-600">
|
|
Click to upload invoices/receipts (PDF, JPG, PNG)
|
|
</span>
|
|
</>
|
|
)}
|
|
</label>
|
|
</div>
|
|
{invoicesReceipts.length > 0 && (
|
|
<div className="mt-3 space-y-2">
|
|
<p className="text-xs font-medium text-gray-600 mb-1">
|
|
Selected Documents ({invoicesReceipts.length}):
|
|
</p>
|
|
{invoicesReceipts.map((file, index) => (
|
|
<div
|
|
key={index}
|
|
className="flex items-start justify-between bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 p-2 sm:p-3 rounded-lg text-xs sm:text-sm shadow-sm hover:shadow-md transition-shadow w-full"
|
|
>
|
|
<div className="flex items-start gap-2 flex-1 min-w-0 pr-2">
|
|
<Receipt className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
|
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
|
{canPreviewFile(file) && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700"
|
|
onClick={() => handlePreviewFile(file)}
|
|
title="Preview file"
|
|
>
|
|
<Eye className="w-3.5 h-3.5" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700"
|
|
onClick={() => handleDownloadFile(file)}
|
|
title="Download file"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700"
|
|
onClick={() => handleRemoveInvoice(index)}
|
|
title="Remove document"
|
|
>
|
|
<X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Attendance Sheet */}
|
|
<div>
|
|
<Label className="text-sm sm:text-base font-semibold">
|
|
Attendance Sheet / Participant List
|
|
</Label>
|
|
<p className="text-xs sm:text-sm text-gray-600 mb-2">
|
|
Upload attendance records or participant lists (if applicable)
|
|
</p>
|
|
<div
|
|
className={`border-2 border-dashed rounded-lg p-3 sm:p-4 transition-all duration-200 ${attendanceSheet
|
|
? 'border-blue-500 bg-blue-50 hover:border-blue-600'
|
|
: 'border-gray-300 hover:border-blue-500 bg-white'
|
|
}`}
|
|
>
|
|
<input
|
|
ref={attendanceInputRef}
|
|
type="file"
|
|
accept={['.pdf', '.xlsx', '.xls', '.csv'].filter(ext => documentPolicy.allowedFileTypes.includes(ext.replace('.', ''))).join(',')}
|
|
className="hidden"
|
|
id="attendanceDoc"
|
|
onChange={handleAttendanceChange}
|
|
/>
|
|
<label
|
|
htmlFor="attendanceDoc"
|
|
className="cursor-pointer flex flex-col items-center gap-2"
|
|
>
|
|
{attendanceSheet ? (
|
|
<>
|
|
<CheckCircle2 className="w-8 h-8 text-blue-600" />
|
|
<div className="flex flex-col items-center gap-1 w-full max-w-full px-2">
|
|
<span className="text-sm font-semibold text-blue-700 break-words text-center w-full max-w-full">
|
|
{attendanceSheet.name}
|
|
</span>
|
|
<span className="text-xs text-blue-600">
|
|
Document selected
|
|
</span>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Upload className="w-8 h-8 text-gray-400" />
|
|
<span className="text-sm text-gray-600">
|
|
Click to upload attendance sheet (Excel, PDF, CSV)
|
|
</span>
|
|
</>
|
|
)}
|
|
</label>
|
|
</div>
|
|
{attendanceSheet && (
|
|
<div className="mt-3">
|
|
<p className="text-xs font-medium text-gray-600 mb-2">
|
|
Selected Document:
|
|
</p>
|
|
<div className="flex items-start justify-between bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 p-2 sm:p-3 rounded-lg text-xs sm:text-sm shadow-sm hover:shadow-md transition-shadow w-full">
|
|
<div className="flex items-start gap-2 flex-1 min-w-0 pr-2">
|
|
<FileText className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
|
<span className="text-gray-800 font-medium break-words break-all">{attendanceSheet.name}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
|
{canPreviewFile(attendanceSheet) && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700"
|
|
onClick={() => handlePreviewFile(attendanceSheet)}
|
|
title="Preview file"
|
|
>
|
|
<Eye className="w-3.5 h-3.5" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700"
|
|
onClick={() => handleDownloadFile(attendanceSheet)}
|
|
title="Download file"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700"
|
|
onClick={() => {
|
|
setAttendanceSheet(null);
|
|
if (attendanceInputRef.current) attendanceInputRef.current.value = '';
|
|
}}
|
|
title="Remove document"
|
|
>
|
|
<X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Completion Description */}
|
|
<div className="space-y-1.5 sm:space-y-2 max-w-3xl">
|
|
<Label className="text-sm sm:text-base font-semibold flex items-center gap-2" htmlFor="completionDescription">
|
|
Brief Description of Completion *
|
|
</Label>
|
|
<Textarea
|
|
id="completionDescription"
|
|
placeholder="Provide a brief description of the completed activity, including key highlights, outcomes, challenges faced, and any relevant observations..."
|
|
value={completionDescription}
|
|
onChange={(e) => setCompletionDescription(e.target.value)}
|
|
className="min-h-[100px] sm:min-h-[120px] text-sm"
|
|
/>
|
|
<p className="text-xs text-gray-500">{completionDescription.length} characters</p>
|
|
</div>
|
|
|
|
{/* Warning Message */}
|
|
{!isFormValid && (
|
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 sm:p-4 flex items-start gap-2 sm:gap-3">
|
|
<CircleAlert className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
|
<div className="text-xs sm:text-sm text-amber-800">
|
|
<p className="font-semibold mb-1">Missing or Invalid Information</p>
|
|
<p>
|
|
Please ensure completion date, documents/photos, description, and expense details (non-negative amounts and descriptions) are provided before submitting.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 px-6 pt-3 pb-6 border-t flex-shrink-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleClose}
|
|
disabled={submitting}
|
|
className="border-2"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={submitting || !isFormValid}
|
|
className="bg-[#2d4a3e] hover:bg-[#1f3329] text-white disabled:bg-gray-300 disabled:text-gray-500"
|
|
>
|
|
{submitting ? 'Submitting...' : 'Submit Documents'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog >
|
|
|
|
{/* File Preview Modal - Matching DocumentsTab style */}
|
|
{
|
|
previewFile && (
|
|
<Dialog
|
|
open={!!previewFile}
|
|
onOpenChange={() => {
|
|
if (previewFile?.url) {
|
|
URL.revokeObjectURL(previewFile.url);
|
|
}
|
|
setPreviewFile(null);
|
|
}}
|
|
>
|
|
<DialogContent className="file-preview-dialog p-3 sm:p-6">
|
|
<div className="file-preview-content">
|
|
<DialogHeader className="pb-4 flex-shrink-0 pr-8">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
<Eye className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<DialogTitle className="text-base sm:text-lg font-bold text-gray-900 truncate pr-2">
|
|
{previewFile.file.name}
|
|
</DialogTitle>
|
|
<p className="text-xs sm:text-sm text-gray-500">
|
|
{previewFile.file.type || 'Unknown type'} • {(previewFile.file.size / 1024).toFixed(1)} KB
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-wrap mr-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleDownloadFile(previewFile.file)}
|
|
className="gap-2 h-9"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
<span className="hidden sm:inline">Download</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="file-preview-body bg-gray-100 rounded-lg p-2 sm:p-4">
|
|
{previewFile.file.type?.includes('image') ? (
|
|
<div className="flex items-center justify-center h-full">
|
|
<img
|
|
src={previewFile.url}
|
|
alt={previewFile.file.name}
|
|
style={{
|
|
maxWidth: '100%',
|
|
maxHeight: '100%',
|
|
objectFit: 'contain'
|
|
}}
|
|
className="rounded-lg shadow-lg"
|
|
/>
|
|
</div>
|
|
) : previewFile.file.type?.includes('pdf') || previewFile.file.name.toLowerCase().endsWith('.pdf') ? (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<iframe
|
|
src={previewFile.url}
|
|
className="w-full h-full rounded-lg border-0"
|
|
title={previewFile.file.name}
|
|
style={{
|
|
minHeight: '70vh',
|
|
height: '100%'
|
|
}}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
|
<div className="w-20 h-20 bg-gray-200 rounded-full flex items-center justify-center mb-4">
|
|
<Eye className="w-10 h-10 text-gray-400" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Preview Not Available</h3>
|
|
<p className="text-sm text-gray-600 mb-6">
|
|
This file type cannot be previewed. Please download to view.
|
|
</p>
|
|
<Button
|
|
onClick={() => handleDownloadFile(previewFile.file)}
|
|
className="gap-2"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
Download {previewFile.file.name}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
</>
|
|
);
|
|
}
|