814 lines
36 KiB
TypeScript
814 lines
36 KiB
TypeScript
/**
|
|
* InitiatorProposalApprovalModal Component
|
|
* Modal for Step 2: Requestor Evaluation & Confirmation
|
|
* Allows initiator to review dealer's proposal and approve/reject
|
|
*/
|
|
|
|
import { useState, useMemo, useEffect } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
CheckCircle,
|
|
XCircle,
|
|
FileText,
|
|
IndianRupee,
|
|
Calendar,
|
|
MessageSquare,
|
|
Download,
|
|
Eye,
|
|
Plus,
|
|
Minus,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
|
|
import { DocumentCard } from '@/components/workflow/DocumentUpload/DocumentCard';
|
|
import { FilePreview } from '@/components/common/FilePreview/FilePreview';
|
|
import '@/components/common/FilePreview/FilePreview.css';
|
|
import './DealerProposalModal.css';
|
|
|
|
interface CostItem {
|
|
id: string;
|
|
description: string;
|
|
amount: number;
|
|
quantity?: number;
|
|
}
|
|
|
|
interface ProposalData {
|
|
proposalDocument?: {
|
|
name: string;
|
|
url?: string;
|
|
id?: string;
|
|
};
|
|
costBreakup: CostItem[];
|
|
expectedCompletionDate: string;
|
|
otherDocuments?: Array<{
|
|
name: string;
|
|
url?: string;
|
|
id?: string;
|
|
}>;
|
|
dealerComments: string;
|
|
submittedAt?: string;
|
|
}
|
|
|
|
interface InitiatorProposalApprovalModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onApprove: (comments: string) => Promise<void>;
|
|
onReject: (comments: string) => Promise<void>;
|
|
onRequestRevision?: (comments: string) => Promise<void>;
|
|
proposalData: ProposalData | null;
|
|
dealerName?: string;
|
|
activityName?: string;
|
|
requestId?: string;
|
|
request?: any; // Request object to check IO blocking status
|
|
previousProposalData?: any;
|
|
taxationType?: string | null;
|
|
}
|
|
|
|
export function InitiatorProposalApprovalModal({
|
|
isOpen,
|
|
onClose,
|
|
onApprove,
|
|
onReject,
|
|
onRequestRevision,
|
|
proposalData,
|
|
dealerName = 'Dealer',
|
|
activityName = 'Activity',
|
|
requestId: _requestId, // Prefix with _ to indicate intentionally unused
|
|
request,
|
|
previousProposalData,
|
|
taxationType,
|
|
}: InitiatorProposalApprovalModalProps) {
|
|
const isNonGst = useMemo(() => {
|
|
return taxationType === 'Non GST' || taxationType === 'Non-GST';
|
|
}, [taxationType]);
|
|
|
|
const [comments, setComments] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [actionType, setActionType] = useState<'approve' | 'reject' | 'revision' | null>(null);
|
|
const [showPreviousProposal, setShowPreviousProposal] = useState(false);
|
|
|
|
// Check if IO is blocked (IO blocking moved to Requestor Evaluation level)
|
|
const internalOrder = request?.internalOrder || request?.internal_order;
|
|
const ioBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
|
|
const isIOBlocked = ioBlockedAmount > 0;
|
|
const [previewDoc, setPreviewDoc] = useState<{
|
|
name: string;
|
|
url: string;
|
|
type?: string;
|
|
size?: number;
|
|
id?: string;
|
|
} | null>(null);
|
|
|
|
// Calculate total budget
|
|
const totalBudget = useMemo(() => {
|
|
if (!proposalData?.costBreakup) return 0;
|
|
|
|
// Ensure costBreakup is an array
|
|
const costBreakup = Array.isArray(proposalData.costBreakup)
|
|
? proposalData.costBreakup
|
|
: (typeof proposalData.costBreakup === 'string'
|
|
? JSON.parse(proposalData.costBreakup)
|
|
: []);
|
|
|
|
if (!Array.isArray(costBreakup)) return 0;
|
|
|
|
return costBreakup.reduce((sum: number, item: any) => {
|
|
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
|
|
const quantity = typeof item === 'object' ? (item.quantity || 1) : 1;
|
|
const baseTotal = amount * quantity;
|
|
const gst = typeof item === 'object' ? (item.gstAmt || 0) : 0;
|
|
const total = item.totalAmt || (baseTotal + gst);
|
|
return sum + (Number(total) || 0);
|
|
}, 0);
|
|
}, [proposalData]);
|
|
|
|
// Format date
|
|
const formatDate = (dateString: string) => {
|
|
if (!dateString) return '—';
|
|
try {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('en-IN', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
});
|
|
} catch {
|
|
return dateString;
|
|
}
|
|
};
|
|
|
|
// Check if document can be previewed
|
|
const canPreviewDocument = (doc: { name: string; url?: string; id?: string }): boolean => {
|
|
if (!doc.name) return false;
|
|
const name = doc.name.toLowerCase();
|
|
return name.endsWith('.pdf') ||
|
|
name.endsWith('.jpg') ||
|
|
name.endsWith('.jpeg') ||
|
|
name.endsWith('.png') ||
|
|
name.endsWith('.gif') ||
|
|
name.endsWith('.webp');
|
|
};
|
|
|
|
// Handle document preview - leverage FilePreview's internal fetching
|
|
const handlePreviewDocument = (doc: { name: string; url?: string; id?: string; storageUrl?: string; documentId?: string }) => {
|
|
let fileUrl = doc.url || doc.storageUrl || '';
|
|
const documentId = doc.id || doc.documentId || '';
|
|
|
|
if (!documentId && !fileUrl) {
|
|
toast.error('Document preview not available');
|
|
return;
|
|
}
|
|
|
|
// Handle relative URLs for snapshots
|
|
if (fileUrl && !fileUrl.startsWith('http') && !fileUrl.startsWith('blob:')) {
|
|
const baseUrl = import.meta.env.VITE_BASE_URL || '';
|
|
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
const cleanFileUrl = fileUrl.startsWith('/') ? fileUrl : `/${fileUrl}`;
|
|
fileUrl = `${cleanBaseUrl}${cleanFileUrl}`;
|
|
}
|
|
|
|
setPreviewDoc({
|
|
name: doc.name || 'Document',
|
|
url: fileUrl || (documentId ? getDocumentPreviewUrl(documentId) : ''),
|
|
type: (doc.name || '').toLowerCase().endsWith('.pdf') ? 'application/pdf' : 'image/jpeg',
|
|
id: documentId
|
|
});
|
|
};
|
|
|
|
// Cleanup blob URLs on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (previewDoc?.url && previewDoc.url.startsWith('blob:')) {
|
|
window.URL.revokeObjectURL(previewDoc.url);
|
|
}
|
|
};
|
|
}, [previewDoc]);
|
|
|
|
const handleApprove = async () => {
|
|
if (!comments.trim()) {
|
|
toast.error('Please provide approval comments');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setSubmitting(true);
|
|
setActionType('approve');
|
|
await onApprove(comments);
|
|
handleReset();
|
|
onClose();
|
|
} catch (error) {
|
|
console.error('Failed to approve proposal:', error);
|
|
toast.error('Failed to approve proposal. Please try again.');
|
|
} finally {
|
|
setSubmitting(false);
|
|
setActionType(null);
|
|
}
|
|
};
|
|
|
|
const handleReject = async () => {
|
|
if (!comments.trim()) {
|
|
toast.error('Please provide rejection reason');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setSubmitting(true);
|
|
setActionType('reject');
|
|
await onReject(comments);
|
|
handleReset();
|
|
onClose();
|
|
} catch (error) {
|
|
console.error('Failed to reject proposal:', error);
|
|
toast.error('Failed to reject proposal. Please try again.');
|
|
} finally {
|
|
setSubmitting(false);
|
|
setActionType(null);
|
|
}
|
|
};
|
|
|
|
const handleRequestRevision = async () => {
|
|
if (!comments.trim()) {
|
|
toast.error('Please provide reasons for requesting a revision');
|
|
return;
|
|
}
|
|
|
|
if (!onRequestRevision) {
|
|
toast.error('Revision feature is not available');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setSubmitting(true);
|
|
setActionType('revision');
|
|
await onRequestRevision(comments);
|
|
handleReset();
|
|
onClose();
|
|
} catch (error) {
|
|
console.error('Failed to request revision:', error);
|
|
toast.error('Failed to request revision. Please try again.');
|
|
} finally {
|
|
setSubmitting(false);
|
|
setActionType(null);
|
|
}
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setComments('');
|
|
setActionType(null);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
if (!submitting) {
|
|
handleReset();
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
// Don't return null - show modal even if proposalData is not loaded yet
|
|
// This allows the modal to open and show a loading/empty state
|
|
if (!isOpen) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
<DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col">
|
|
<DialogHeader className="flex-shrink-0 pb-3 lg:pb-4 px-6 pt-4 lg:pt-6 border-b">
|
|
<DialogTitle className="flex items-center gap-2 text-lg lg:text-xl flex-wrap">
|
|
<div className="flex items-center gap-2">
|
|
<CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-green-600" />
|
|
Requestor Evaluation & Confirmation
|
|
</div>
|
|
{taxationType && (
|
|
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
|
|
{!isNonGst ? 'GST Claim' : 'Non-GST Claim'}
|
|
</Badge>
|
|
)}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs lg:text-sm">
|
|
Step 2: Review dealer proposal and make a decision
|
|
</DialogDescription>
|
|
<div className="space-y-1 mt-2 text-xs text-gray-600">
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
|
<div>
|
|
<strong>Dealer:</strong> {dealerName}
|
|
</div>
|
|
<div>
|
|
<strong>Activity:</strong> {activityName}
|
|
</div>
|
|
</div>
|
|
<div className="mt-1 text-amber-600 font-medium">
|
|
Decision: <strong>Confirms?</strong> (YES → Continue to Dept Lead / NO → Request is cancelled)
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 py-3 lg:py-4 px-6">
|
|
|
|
{/* Previous Proposal Reference Section */}
|
|
{previousProposalData && (
|
|
<div className="mb-6">
|
|
<div
|
|
className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden cursor-pointer hover:bg-amber-100/50 transition-colors"
|
|
onClick={() => setShowPreviousProposal(!showPreviousProposal)}
|
|
>
|
|
<div className="px-4 py-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<FileText className="w-4 h-4 text-amber-700" />
|
|
<span className="text-sm font-semibold text-amber-900">Reference: Previous Proposal Details (last revision)</span>
|
|
<Badge variant="secondary" className="bg-amber-200 text-amber-800 text-[10px]">
|
|
₹{Number(previousProposalData.totalEstimatedBudget || previousProposalData.totalBudget || 0).toLocaleString('en-IN')}
|
|
</Badge>
|
|
</div>
|
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-amber-700">
|
|
{showPreviousProposal ? <Minus className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
|
|
</Button>
|
|
</div>
|
|
|
|
{showPreviousProposal && (
|
|
<div className="px-4 pb-4 border-t border-amber-200 space-y-4 bg-white/50">
|
|
{/* Header Info: Date & Document */}
|
|
<div className="flex flex-wrap gap-4 text-xs mt-3">
|
|
{previousProposalData.expectedCompletionDate && (
|
|
<div className="flex items-center gap-1.5 text-gray-700">
|
|
<Calendar className="w-3.5 h-3.5 text-gray-500" />
|
|
<span className="font-medium">Expected Completion:</span>
|
|
<span>{new Date(previousProposalData.expectedCompletionDate).toLocaleDateString('en-IN')}</span>
|
|
</div>
|
|
)}
|
|
|
|
{previousProposalData.documentUrl && (
|
|
<div className="flex items-center gap-1.5">
|
|
{canPreviewDocument({ name: previousProposalData.documentUrl }) ? (
|
|
<>
|
|
<Eye className="w-3.5 h-3.5 text-blue-500" />
|
|
<a
|
|
href={previousProposalData.documentUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
|
|
>
|
|
View Previous Document
|
|
</a>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Download className="w-3.5 h-3.5 text-blue-500" />
|
|
<a
|
|
href={previousProposalData.documentUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
|
|
>
|
|
Download Previous Document
|
|
</a>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Cost Breakdown */}
|
|
{(previousProposalData.costItems || previousProposalData.costBreakup) && (previousProposalData.costItems || previousProposalData.costBreakup).length > 0 && (
|
|
<div className="w-full pt-2 border-t border-amber-200/50">
|
|
<p className="text-[10px] font-semibold text-gray-700 mb-2 flex items-center gap-1">
|
|
<IndianRupee className="w-3 h-3" />
|
|
Previous Cost Breakdown
|
|
</p>
|
|
<div className="border rounded-md overflow-hidden text-[10px]">
|
|
<table className="w-full text-left">
|
|
<thead className="bg-gray-50 text-gray-600">
|
|
<tr>
|
|
<th className="p-2 font-medium">Description</th>
|
|
<th className="p-2 font-medium text-right">Amount</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{(previousProposalData.costItems || previousProposalData.costBreakup).map((item: any, idx: number) => (
|
|
<tr key={idx} className="bg-white">
|
|
<td className="p-2 text-gray-800">{item.description}</td>
|
|
<td className="p-2 text-right text-gray-800 font-medium">
|
|
₹{Number(item.amount).toLocaleString('en-IN')}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
<tr className="bg-gray-50 font-bold border-t">
|
|
<td className="p-2 text-gray-900">Total</td>
|
|
<td className="p-2 text-right text-gray-900">
|
|
₹{Number(previousProposalData.totalEstimatedBudget || previousProposalData.totalBudget || 0).toLocaleString('en-IN')}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Additional/Supporting Documents */}
|
|
{previousProposalData.otherDocuments && previousProposalData.otherDocuments.length > 0 && (
|
|
<div className="w-full pt-2 border-t border-amber-200/50">
|
|
<p className="text-[10px] font-semibold text-gray-700 mb-1.5 flex items-center gap-1">
|
|
<FileText className="w-3 h-3" />
|
|
Supporting Documents
|
|
</p>
|
|
<div className="space-y-2 max-h-[150px] overflow-y-auto">
|
|
{previousProposalData.otherDocuments.map((doc: any, idx: number) => (
|
|
<DocumentCard
|
|
key={idx}
|
|
document={{
|
|
documentId: doc.documentId || doc.id || '',
|
|
name: doc.originalFileName || doc.fileName || doc.name || 'Supporting Document',
|
|
fileType: (doc.originalFileName || doc.fileName || doc.name || '').split('.').pop() || 'file',
|
|
uploadedAt: doc.uploadedAt || new Date().toISOString()
|
|
}}
|
|
onPreview={canPreviewDocument({ name: doc.originalFileName || doc.fileName || doc.name || '' }) ? () => handlePreviewDocument(doc) : undefined}
|
|
onDownload={async (id) => {
|
|
if (id) {
|
|
await downloadDocument(id);
|
|
} else {
|
|
let downloadUrl = doc.storageUrl || doc.documentUrl;
|
|
if (downloadUrl && !downloadUrl.startsWith('http')) {
|
|
const baseUrl = import.meta.env.VITE_BASE_URL || '';
|
|
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
|
|
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
|
|
}
|
|
if (downloadUrl) window.open(downloadUrl, '_blank');
|
|
}
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Comments */}
|
|
{(previousProposalData.comments || previousProposalData.dealerComments) && (
|
|
<div className="w-full pt-2 border-t border-amber-200/50">
|
|
<p className="text-[10px] font-semibold text-gray-700 mb-1 flex items-center gap-1">
|
|
<MessageSquare className="w-3 h-3" />
|
|
Previous Comments
|
|
</p>
|
|
<div className="text-[10px] text-gray-600 bg-white p-2 border border-gray-100 rounded italic">
|
|
"{previousProposalData.comments || previousProposalData.dealerComments}"
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-4 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6 lg:items-start lg:content-start">
|
|
{/* Left Column - Documents */}
|
|
<div className="space-y-4 lg:space-y-4 flex flex-col">
|
|
{/* Proposal Document Section */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
|
<FileText className="w-4 h-4 text-blue-600" />
|
|
Proposal Document
|
|
</h3>
|
|
</div>
|
|
{proposalData?.proposalDocument ? (
|
|
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
|
<FileText className="w-5 h-5 lg:w-6 lg:h-6 text-blue-600 flex-shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={proposalData.proposalDocument.name}>
|
|
{proposalData.proposalDocument.name}
|
|
</p>
|
|
{proposalData?.submittedAt && (
|
|
<p className="text-xs text-gray-500 truncate">
|
|
Submitted on {formatDate(proposalData.submittedAt)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
{proposalData.proposalDocument.id && (
|
|
<>
|
|
{canPreviewDocument(proposalData.proposalDocument) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handlePreviewDocument(proposalData.proposalDocument!)}
|
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
title="Preview document"
|
|
>
|
|
<Eye className="w-5 h-5 text-blue-600" />
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
try {
|
|
if (proposalData.proposalDocument?.id) {
|
|
await downloadDocument(proposalData.proposalDocument.id);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to download document:', error);
|
|
toast.error('Failed to download document');
|
|
}
|
|
}}
|
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
title="Download document"
|
|
>
|
|
<Download className="w-5 h-5 text-gray-600" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-gray-500 italic">No proposal document available</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Other Supporting Documents */}
|
|
{proposalData?.otherDocuments && proposalData.otherDocuments.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
|
<FileText className="w-4 h-4 text-gray-600" />
|
|
Other Supporting Documents
|
|
</h3>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{proposalData.otherDocuments.length} file(s)
|
|
</Badge>
|
|
</div>
|
|
<div className="space-y-2 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
|
|
{proposalData.otherDocuments.map((doc, index) => (
|
|
<div
|
|
key={index}
|
|
className="border rounded-lg p-2 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"
|
|
>
|
|
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
|
<FileText className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600 flex-shrink-0" />
|
|
<p className="text-xs lg:text-sm font-medium text-gray-900 truncate" title={doc.name}>
|
|
{doc.name}
|
|
</p>
|
|
</div>
|
|
{doc.id && (
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
{canPreviewDocument(doc) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handlePreviewDocument(doc)}
|
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
title="Preview document"
|
|
>
|
|
<Eye className="w-5 h-5 text-blue-600" />
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
try {
|
|
if (doc.id) {
|
|
await downloadDocument(doc.id);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to download document:', error);
|
|
toast.error('Failed to download document');
|
|
}
|
|
}}
|
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
title="Download document"
|
|
>
|
|
<Download className="w-5 h-5 text-gray-600" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right Column - Planning & Details */}
|
|
<div className="space-y-4 lg:space-y-4 flex flex-col">
|
|
{/* Cost Breakup Section */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
|
<IndianRupee className="w-4 h-4 text-green-600" />
|
|
Cost Breakup
|
|
</h3>
|
|
</div>
|
|
{(() => {
|
|
// Ensure costBreakup is an array
|
|
const costBreakup = proposalData?.costBreakup
|
|
? (Array.isArray(proposalData.costBreakup)
|
|
? proposalData.costBreakup
|
|
: (typeof proposalData.costBreakup === 'string'
|
|
? JSON.parse(proposalData.costBreakup)
|
|
: []))
|
|
: [];
|
|
|
|
return costBreakup && Array.isArray(costBreakup) && costBreakup.length > 0 ? (
|
|
<>
|
|
<div className="border rounded-lg overflow-hidden max-h-[200px] lg:max-h-[180px] overflow-y-auto">
|
|
<div className="bg-gray-50 px-3 lg:px-4 py-2 border-b sticky top-0">
|
|
<div className={`grid ${isNonGst ? 'grid-cols-3' : 'grid-cols-4'} gap-4 text-xs lg:text-sm font-semibold text-gray-700`}>
|
|
<div className="col-span-1">Item Description</div>
|
|
<div className="text-right">Base</div>
|
|
{!isNonGst && <div className="text-right">GST</div>}
|
|
<div className="text-right">Total</div>
|
|
</div>
|
|
</div>
|
|
<div className="divide-y">
|
|
{costBreakup.map((item: any, index: number) => (
|
|
<div key={item?.id || item?.description || index} className={`px-3 lg:px-4 py-2 lg:py-3 grid ${isNonGst ? 'grid-cols-3' : 'grid-cols-4'} gap-4`}>
|
|
<div className="col-span-1 text-xs lg:text-sm text-gray-700">
|
|
{item?.description || 'N/A'}
|
|
{!isNonGst && item?.gstRate ? <span className="block text-[10px] text-gray-400">{item.gstRate}% GST</span> : null}
|
|
</div>
|
|
<div className="text-xs lg:text-sm text-gray-900 text-right">
|
|
₹{(Number(item?.amount) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
</div>
|
|
{!isNonGst && (
|
|
<div className="text-xs lg:text-sm text-gray-900 text-right">
|
|
₹{(Number(item?.gstAmt) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
</div>
|
|
)}
|
|
<div className="text-xs lg:text-sm font-semibold text-gray-900 text-right">
|
|
₹{(Number(item?.totalAmt || ((item?.amount || 0) * (item?.quantity || 1) + (item?.gstAmt || 0))) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="border-2 border-[--re-green] rounded-lg p-2.5 lg:p-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<IndianRupee className="w-4 h-4 text-[--re-green]" />
|
|
<span className="font-semibold text-xs lg:text-sm text-gray-700">Total Estimated Budget</span>
|
|
</div>
|
|
<div className="text-lg lg:text-xl font-bold text-[--re-green]">
|
|
₹{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p className="text-xs text-gray-500 italic">No cost breakdown available</p>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* Timeline Section */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
|
<Calendar className="w-4 h-4 text-purple-600" />
|
|
Expected Completion Date
|
|
</h3>
|
|
</div>
|
|
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50">
|
|
<p className="text-sm lg:text-base font-semibold text-gray-900">
|
|
{proposalData?.expectedCompletionDate ? formatDate(proposalData.expectedCompletionDate) : 'Not specified'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Comments Section - Side by Side */}
|
|
<div className="space-y-2 border-t pt-3 lg:pt-3 lg:col-span-2 mt-2">
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
|
|
{/* Dealer Comments */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
|
<MessageSquare className="w-4 h-4 text-blue-600" />
|
|
Dealer Comments
|
|
</h3>
|
|
</div>
|
|
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
|
|
<p className="text-xs text-gray-700 whitespace-pre-wrap">
|
|
{proposalData?.dealerComments || 'No comments provided'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Your Decision & Comments */}
|
|
<div className="space-y-2">
|
|
<h3 className="font-semibold text-sm lg:text-base">Your Decision & Comments</h3>
|
|
<Textarea
|
|
placeholder="Provide your evaluation comments, approval conditions, or rejection reasons..."
|
|
value={comments}
|
|
onChange={(e) => setComments(e.target.value)}
|
|
className="min-h-[150px] lg:min-h-[140px] text-xs lg:text-sm w-full"
|
|
/>
|
|
<p className="text-xs text-gray-500">{comments.length} characters</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Warning for missing comments */}
|
|
{!comments.trim() && (
|
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 flex items-start gap-2 lg:col-span-2">
|
|
<XCircle className="w-3.5 h-3.5 text-amber-600 flex-shrink-0 mt-0.5" />
|
|
<p className="text-xs text-amber-800">
|
|
Please provide comments before making a decision. Comments are required and will be visible to all participants.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end px-6 pb-6 pt-3 lg:pt-4 flex-shrink-0 border-t bg-gray-50">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleClose}
|
|
disabled={submitting}
|
|
className="border-2 w-full sm:w-auto"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<div className="flex flex-col gap-2 w-full sm:w-auto">
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<Button
|
|
onClick={handleRequestRevision}
|
|
disabled={!comments.trim() || submitting}
|
|
variant="secondary"
|
|
className="bg-amber-100 hover:bg-amber-200 text-amber-900 border border-amber-200 w-full sm:w-auto"
|
|
>
|
|
{submitting && actionType === 'revision' ? (
|
|
'Requesting...'
|
|
) : (
|
|
<>
|
|
<MessageSquare className="w-4 h-4 mr-2" />
|
|
Request Revised Quotation
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
onClick={handleReject}
|
|
disabled={!comments.trim() || submitting}
|
|
variant="destructive"
|
|
className="bg-red-600 hover:bg-red-700 w-full sm:w-auto"
|
|
>
|
|
{submitting && actionType === 'reject' ? (
|
|
'Rejecting...'
|
|
) : (
|
|
<>
|
|
<XCircle className="w-4 h-4 mr-2" />
|
|
Reject (Cancel Request)
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
onClick={handleApprove}
|
|
disabled={!comments.trim() || !isIOBlocked || submitting}
|
|
className="bg-green-600 hover:bg-green-700 text-white disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto"
|
|
title={!isIOBlocked ? 'Please block IO budget before approving' : ''}
|
|
>
|
|
{submitting && actionType === 'approve' ? (
|
|
'Approving...'
|
|
) : (
|
|
<>
|
|
<CheckCircle className="w-4 h-4 mr-2" />
|
|
Approve (Continue to Dept Lead)
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
{/* Warning for IO not blocked - shown below Approve button */}
|
|
{!isIOBlocked && (
|
|
<p className="text-xs text-red-600 text-center sm:text-left">
|
|
Please block IO budget in the IO Tab before approving
|
|
</p>
|
|
)}
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
|
|
{/* Standardized File Preview */}
|
|
{previewDoc && (
|
|
<FilePreview
|
|
fileName={previewDoc.name}
|
|
fileType={previewDoc.type || ''}
|
|
fileUrl={previewDoc.url}
|
|
fileSize={previewDoc.size}
|
|
attachmentId={previewDoc.id}
|
|
onDownload={downloadDocument}
|
|
open={!!previewDoc}
|
|
onClose={() => setPreviewDoc(null)}
|
|
/>
|
|
)}
|
|
</Dialog>
|
|
);
|
|
}
|