537 lines
20 KiB
TypeScript
537 lines
20 KiB
TypeScript
/**
|
|
* DealerProposalSubmissionModal Component
|
|
* Modal for Step 1: Dealer Proposal Submission
|
|
* Allows dealers to upload proposal documents, provide cost breakdown, timeline, and comments
|
|
*/
|
|
|
|
import { useState, useRef, useMemo } 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 { Upload, Plus, X, Calendar, DollarSign, CircleAlert, CheckCircle2, FileText } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
interface CostItem {
|
|
id: string;
|
|
description: string;
|
|
amount: number;
|
|
}
|
|
|
|
interface DealerProposalSubmissionModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSubmit: (data: {
|
|
proposalDocument: File | null;
|
|
costBreakup: CostItem[];
|
|
expectedCompletionDate: string;
|
|
otherDocuments: File[];
|
|
dealerComments: string;
|
|
}) => Promise<void>;
|
|
dealerName?: string;
|
|
activityName?: string;
|
|
requestId?: string;
|
|
}
|
|
|
|
export function DealerProposalSubmissionModal({
|
|
isOpen,
|
|
onClose,
|
|
onSubmit,
|
|
dealerName = 'Jaipur Royal Enfield',
|
|
activityName = 'Activity',
|
|
requestId: _requestId,
|
|
}: DealerProposalSubmissionModalProps) {
|
|
const [proposalDocument, setProposalDocument] = useState<File | null>(null);
|
|
const [costItems, setCostItems] = useState<CostItem[]>([
|
|
{ id: '1', description: '', amount: 0 },
|
|
]);
|
|
const [timelineMode, setTimelineMode] = useState<'date' | 'days'>('date');
|
|
const [expectedCompletionDate, setExpectedCompletionDate] = useState('');
|
|
const [numberOfDays, setNumberOfDays] = useState('');
|
|
const [otherDocuments, setOtherDocuments] = useState<File[]>([]);
|
|
const [dealerComments, setDealerComments] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const proposalDocInputRef = useRef<HTMLInputElement>(null);
|
|
const otherDocsInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Calculate total estimated budget
|
|
const totalBudget = useMemo(() => {
|
|
return costItems.reduce((sum, item) => sum + (item.amount || 0), 0);
|
|
}, [costItems]);
|
|
|
|
// Check if all required fields are filled
|
|
const isFormValid = useMemo(() => {
|
|
const hasProposalDoc = proposalDocument !== null;
|
|
const hasValidCostItems = costItems.length > 0 &&
|
|
costItems.every(item => item.description.trim() !== '' && item.amount > 0);
|
|
const hasTimeline = timelineMode === 'date'
|
|
? expectedCompletionDate !== ''
|
|
: numberOfDays !== '' && parseInt(numberOfDays) > 0;
|
|
const hasComments = dealerComments.trim().length > 0;
|
|
|
|
return hasProposalDoc && hasValidCostItems && hasTimeline && hasComments;
|
|
}, [proposalDocument, costItems, timelineMode, expectedCompletionDate, numberOfDays, dealerComments]);
|
|
|
|
const handleProposalDocChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
// Validate file type
|
|
const allowedTypes = ['.pdf', '.doc', '.docx'];
|
|
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
|
|
if (!allowedTypes.includes(fileExtension)) {
|
|
toast.error('Please upload a PDF, DOC, or DOCX file');
|
|
return;
|
|
}
|
|
setProposalDocument(file);
|
|
}
|
|
};
|
|
|
|
const handleOtherDocsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(e.target.files || []);
|
|
setOtherDocuments(prev => [...prev, ...files]);
|
|
};
|
|
|
|
const handleAddCostItem = () => {
|
|
setCostItems(prev => [
|
|
...prev,
|
|
{ id: Date.now().toString(), description: '', amount: 0 },
|
|
]);
|
|
};
|
|
|
|
const handleRemoveCostItem = (id: string) => {
|
|
if (costItems.length > 1) {
|
|
setCostItems(prev => prev.filter(item => item.id !== id));
|
|
}
|
|
};
|
|
|
|
const handleCostItemChange = (id: string, field: 'description' | 'amount', value: string | number) => {
|
|
setCostItems(prev =>
|
|
prev.map(item =>
|
|
item.id === id
|
|
? { ...item, [field]: field === 'amount' ? parseFloat(value.toString()) || 0 : value }
|
|
: item
|
|
)
|
|
);
|
|
};
|
|
|
|
const handleRemoveOtherDoc = (index: number) => {
|
|
setOtherDocuments(prev => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!isFormValid) {
|
|
toast.error('Please fill all required fields');
|
|
return;
|
|
}
|
|
|
|
// Calculate final completion date if using days mode
|
|
let finalCompletionDate: string = expectedCompletionDate || '';
|
|
if (timelineMode === 'days' && numberOfDays) {
|
|
const days = parseInt(numberOfDays);
|
|
const date = new Date();
|
|
date.setDate(date.getDate() + days);
|
|
const isoString = date.toISOString();
|
|
finalCompletionDate = isoString.split('T')[0] as string;
|
|
}
|
|
|
|
try {
|
|
setSubmitting(true);
|
|
await onSubmit({
|
|
proposalDocument,
|
|
costBreakup: costItems.filter(item => item.description.trim() !== '' && item.amount > 0),
|
|
expectedCompletionDate: finalCompletionDate,
|
|
otherDocuments,
|
|
dealerComments,
|
|
});
|
|
handleReset();
|
|
onClose();
|
|
} catch (error) {
|
|
console.error('Failed to submit proposal:', error);
|
|
toast.error('Failed to submit proposal. Please try again.');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setProposalDocument(null);
|
|
setCostItems([{ id: '1', description: '', amount: 0 }]);
|
|
setTimelineMode('date');
|
|
setExpectedCompletionDate('');
|
|
setNumberOfDays('');
|
|
setOtherDocuments([]);
|
|
setDealerComments('');
|
|
if (proposalDocInputRef.current) proposalDocInputRef.current.value = '';
|
|
if (otherDocsInputRef.current) otherDocsInputRef.current.value = '';
|
|
};
|
|
|
|
const handleClose = () => {
|
|
if (!submitting) {
|
|
handleReset();
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
// Get minimum date (today)
|
|
const minDate = new Date().toISOString().split('T')[0];
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto overflow-x-hidden">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-2xl">
|
|
<Upload className="w-6 h-6 text-[--re-green]" />
|
|
Dealer Proposal Submission
|
|
</DialogTitle>
|
|
<DialogDescription className="text-base">
|
|
Step 1: Upload proposal and planning details
|
|
</DialogDescription>
|
|
<div className="space-y-1 mt-2 text-sm text-gray-600">
|
|
<div>
|
|
<strong>Dealer:</strong> {dealerName}
|
|
</div>
|
|
<div>
|
|
<strong>Activity:</strong> {activityName}
|
|
</div>
|
|
<div className="mt-2">
|
|
Please upload proposal document, provide cost breakdown, timeline, and detailed comments.
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-6 py-4">
|
|
{/* Proposal Document Section */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-lg">Proposal Document</h3>
|
|
<Badge className="bg-red-500 text-white text-xs">Required</Badge>
|
|
</div>
|
|
<div>
|
|
<Label className="text-base font-semibold flex items-center gap-2">
|
|
Proposal Document *
|
|
</Label>
|
|
<p className="text-sm text-gray-600 mb-2">
|
|
Detailed proposal with activity details and requested information
|
|
</p>
|
|
<div
|
|
className={`border-2 border-dashed rounded-lg p-4 transition-all duration-200 ${
|
|
proposalDocument
|
|
? 'border-green-500 bg-green-50 hover:border-green-600'
|
|
: 'border-gray-300 hover:border-blue-500 bg-white'
|
|
}`}
|
|
>
|
|
<input
|
|
ref={proposalDocInputRef}
|
|
type="file"
|
|
accept=".pdf,.doc,.docx"
|
|
className="hidden"
|
|
id="proposalDoc"
|
|
onChange={handleProposalDocChange}
|
|
/>
|
|
<label
|
|
htmlFor="proposalDoc"
|
|
className="cursor-pointer flex flex-col items-center gap-2"
|
|
>
|
|
{proposalDocument ? (
|
|
<>
|
|
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
|
<div className="flex flex-col items-center gap-1 w-full max-w-full px-2">
|
|
<span className="text-sm font-semibold text-green-700 break-words text-center w-full max-w-full">
|
|
{proposalDocument.name}
|
|
</span>
|
|
<span className="text-xs text-green-600">
|
|
Document selected
|
|
</span>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Upload className="w-8 h-8 text-gray-400" />
|
|
<span className="text-sm text-gray-600">
|
|
Click to upload proposal (PDF, DOC, DOCX)
|
|
</span>
|
|
</>
|
|
)}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cost Breakup Section */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-lg">Cost Breakup</h3>
|
|
<Badge className="bg-red-500 text-white text-xs">Required</Badge>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
onClick={handleAddCostItem}
|
|
className="bg-[#2d4a3e] hover:bg-[#1f3329] text-white"
|
|
size="sm"
|
|
>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
Add Item
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{costItems.map((item) => (
|
|
<div key={item.id} className="flex gap-2 items-start">
|
|
<div className="flex-1">
|
|
<Input
|
|
placeholder="Item description (e.g., Banner printing, Event setup)"
|
|
value={item.description}
|
|
onChange={(e) =>
|
|
handleCostItemChange(item.id, 'description', e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="w-40">
|
|
<Input
|
|
type="number"
|
|
placeholder="Amount"
|
|
min="0"
|
|
step="0.01"
|
|
value={item.amount || ''}
|
|
onChange={(e) =>
|
|
handleCostItemChange(item.id, 'amount', e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="mt-0.5 hover:bg-red-100 hover:text-red-700"
|
|
onClick={() => handleRemoveCostItem(item.id)}
|
|
disabled={costItems.length === 1}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="border-2 border-gray-300 rounded-lg p-4 bg-white">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<DollarSign className="w-5 h-5 text-gray-700" />
|
|
<span className="font-semibold text-gray-900">Estimated Budget</span>
|
|
</div>
|
|
<div className="text-2xl font-bold text-gray-900">
|
|
₹{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Timeline for Closure Section */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-lg">Timeline for Closure</h3>
|
|
<Badge className="bg-red-500 text-white text-xs">Required</Badge>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
onClick={() => setTimelineMode('date')}
|
|
className={
|
|
timelineMode === 'date'
|
|
? 'bg-[#2d4a3e] hover:bg-[#1f3329] text-white'
|
|
: 'border-2 hover:bg-gray-50'
|
|
}
|
|
size="sm"
|
|
>
|
|
<Calendar className="w-4 h-4 mr-1" />
|
|
Specific Date
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => setTimelineMode('days')}
|
|
className={
|
|
timelineMode === 'days'
|
|
? 'bg-[#2d4a3e] hover:bg-[#1f3329] text-white'
|
|
: 'border-2 hover:bg-gray-50'
|
|
}
|
|
size="sm"
|
|
>
|
|
Number of Days
|
|
</Button>
|
|
</div>
|
|
{timelineMode === 'date' ? (
|
|
<div>
|
|
<Label className="text-sm font-medium mb-2 block">
|
|
Expected Completion Date
|
|
</Label>
|
|
<Input
|
|
type="date"
|
|
min={minDate}
|
|
value={expectedCompletionDate}
|
|
onChange={(e) => setExpectedCompletionDate(e.target.value)}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<Label className="text-sm font-medium mb-2 block">
|
|
Number of Days
|
|
</Label>
|
|
<Input
|
|
type="number"
|
|
placeholder="Enter number of days"
|
|
min="1"
|
|
value={numberOfDays}
|
|
onChange={(e) => setNumberOfDays(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Other Supporting Documents Section */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-lg">Other Supporting Documents</h3>
|
|
<Badge variant="secondary" className="text-xs">Optional</Badge>
|
|
</div>
|
|
<div>
|
|
<Label className="flex items-center gap-2 text-base font-semibold">
|
|
Additional Documents
|
|
</Label>
|
|
<p className="text-sm text-gray-600 mb-2">
|
|
Any other supporting documents (invoices, receipts, photos, etc.)
|
|
</p>
|
|
<div
|
|
className={`border-2 border-dashed rounded-lg p-4 transition-all duration-200 ${
|
|
otherDocuments.length > 0
|
|
? 'border-blue-500 bg-blue-50 hover:border-blue-600'
|
|
: 'border-gray-300 hover:border-blue-500 bg-white'
|
|
}`}
|
|
>
|
|
<input
|
|
ref={otherDocsInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
id="otherDocs"
|
|
onChange={handleOtherDocsChange}
|
|
/>
|
|
<label
|
|
htmlFor="otherDocs"
|
|
className="cursor-pointer flex flex-col items-center gap-2"
|
|
>
|
|
{otherDocuments.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">
|
|
{otherDocuments.length} document{otherDocuments.length !== 1 ? 's' : ''} selected
|
|
</span>
|
|
<span className="text-xs text-blue-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 additional documents (multiple files allowed)
|
|
</span>
|
|
</>
|
|
)}
|
|
</label>
|
|
</div>
|
|
{otherDocuments.length > 0 && (
|
|
<div className="mt-3 space-y-2">
|
|
<p className="text-xs font-medium text-gray-600 mb-1">
|
|
Selected Documents ({otherDocuments.length}):
|
|
</p>
|
|
{otherDocuments.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-3 rounded-lg 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-4 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>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2"
|
|
onClick={() => handleRemoveOtherDoc(index)}
|
|
title="Remove document"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dealer Comments Section */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="dealerComments" className="text-base font-semibold flex items-center gap-2">
|
|
Dealer Comments / Details *
|
|
</Label>
|
|
<Textarea
|
|
id="dealerComments"
|
|
placeholder="Provide detailed comments about this claim request, including any special considerations, execution details, or additional information..."
|
|
value={dealerComments}
|
|
onChange={(e) => setDealerComments(e.target.value)}
|
|
className="min-h-[120px]"
|
|
/>
|
|
<p className="text-xs text-gray-500">{dealerComments.length} characters</p>
|
|
</div>
|
|
|
|
{/* Warning Message */}
|
|
{!isFormValid && (
|
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
|
|
<CircleAlert className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
|
<div className="text-sm text-amber-800">
|
|
<p className="font-semibold mb-1">Missing Required Information</p>
|
|
<p>
|
|
Please ensure proposal document, cost breakup, timeline, and dealer comments are provided before submitting.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleClose}
|
|
disabled={submitting}
|
|
className="border-2"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={!isFormValid || submitting}
|
|
className="bg-[#2d4a3e] hover:bg-[#1f3329] text-white disabled:bg-gray-300 disabled:text-gray-500"
|
|
>
|
|
{submitting ? 'Submitting...' : 'Submit Documents'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|