Re_Figma_Code/src/dealer-claim/components/request-detail/modals/InitiatorProposalApprovalModal.tsx

682 lines
26 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,
DollarSign,
Calendar,
MessageSquare,
Download,
Eye,
Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
import '@/components/common/FilePreview/FilePreview.css';
import './DealerProposalModal.css';
interface CostItem {
id: string;
description: string;
amount: 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>;
proposalData: ProposalData | null;
dealerName?: string;
activityName?: string;
requestId?: string;
}
export function InitiatorProposalApprovalModal({
isOpen,
onClose,
onApprove,
onReject,
proposalData,
dealerName = 'Dealer',
activityName = 'Activity',
requestId: _requestId, // Prefix with _ to indicate intentionally unused
}: InitiatorProposalApprovalModalProps) {
const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | null>(null);
const [previewDocument, setPreviewDocument] = useState<{
name: string;
url: string;
type?: string;
size?: number;
} | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
// 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;
return sum + (Number(amount) || 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 - fetch as blob to avoid CSP issues
const handlePreviewDocument = async (doc: { name: string; url?: string; id?: string }) => {
if (!doc.id) {
toast.error('Document preview not available - document ID missing');
return;
}
setPreviewLoading(true);
try {
const previewUrl = getDocumentPreviewUrl(doc.id);
// Determine file type from name
const fileName = doc.name.toLowerCase();
const isPDF = fileName.endsWith('.pdf');
const isImage = fileName.match(/\.(jpg|jpeg|png|gif|webp)$/i);
// Fetch the document as a blob to create a blob URL (CSP compliant)
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
const token = isProduction ? null : localStorage.getItem('accessToken');
const headers: HeadersInit = {
'Accept': isPDF ? 'application/pdf' : '*/*'
};
if (!isProduction && token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(previewUrl, {
headers,
credentials: 'include',
mode: 'cors'
});
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
if (blob.size === 0) {
throw new Error('File is empty or could not be loaded');
}
// Create blob URL (CSP compliant - uses 'blob:' protocol)
const blobUrl = window.URL.createObjectURL(blob);
setPreviewDocument({
name: doc.name,
url: blobUrl,
type: blob.type || (isPDF ? 'application/pdf' : isImage ? 'image' : undefined),
size: blob.size,
});
} catch (error) {
console.error('Failed to load document preview:', error);
toast.error('Failed to load document preview');
} finally {
setPreviewLoading(false);
}
};
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
if (previewDocument?.url && previewDocument.url.startsWith('blob:')) {
window.URL.revokeObjectURL(previewDocument.url);
}
};
}, [previewDocument]);
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 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">
<CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-green-600" />
Requestor Evaluation & Confirmation
</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 py-3 lg:py-4 px-6">
<div className="space-y-4 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6">
{/* Proposal Document Section */}
<div className="space-y-2 lg: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">
<div className="flex items-center gap-2 lg:gap-3">
<FileText className="w-5 h-5 lg:w-6 lg:h-6 text-blue-600" />
<div>
<p className="font-medium text-xs lg:text-sm text-gray-900">{proposalData.proposalDocument.name}</p>
{proposalData?.submittedAt && (
<p className="text-xs text-gray-500">
Submitted on {formatDate(proposalData.submittedAt)}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{proposalData.proposalDocument.id && (
<>
{canPreviewDocument(proposalData.proposalDocument) && (
<button
type="button"
onClick={() => handlePreviewDocument(proposalData.proposalDocument!)}
disabled={previewLoading}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Preview document"
>
{previewLoading ? (
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
) : (
<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>
{/* Cost Breakup Section */}
<div className="space-y-2 lg:space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<DollarSign 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 grid-cols-2 gap-4 text-xs lg:text-sm font-semibold text-gray-700">
<div>Item Description</div>
<div className="text-right">Amount</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 grid-cols-2 gap-4">
<div className="text-xs lg:text-sm text-gray-700">{item?.description || 'N/A'}</div>
<div className="text-xs lg:text-sm font-semibold text-gray-900 text-right">
{(Number(item?.amount) || 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">
<DollarSign 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 lg: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>
{/* Other Supporting Documents */}
{proposalData?.otherDocuments && proposalData.otherDocuments.length > 0 && (
<div className="space-y-2 lg: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"
>
<div className="flex items-center gap-2 lg:gap-3">
<FileText className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600" />
<p className="text-xs lg:text-sm font-medium text-gray-900">{doc.name}</p>
</div>
{doc.id && (
<div className="flex items-center gap-1">
{canPreviewDocument(doc) && (
<button
type="button"
onClick={() => handlePreviewDocument(doc)}
disabled={previewLoading}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Preview document"
>
{previewLoading ? (
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
) : (
<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>
)}
{/* Dealer Comments */}
<div className="space-y-2 lg: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>
{/* Decision Section */}
<div className="space-y-2 lg:space-y-2 border-t pt-3 lg:pt-3 lg:col-span-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-[80px] lg:min-h-[90px] text-xs lg:text-sm"
/>
<p className="text-xs text-gray-500">{comments.length} characters</p>
</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-reverse 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"
>
Cancel
</Button>
<div className="flex gap-2">
<Button
onClick={handleReject}
disabled={!comments.trim() || submitting}
variant="destructive"
className="bg-red-600 hover:bg-red-700"
>
{submitting && actionType === 'reject' ? (
'Rejecting...'
) : (
<>
<XCircle className="w-4 h-4 mr-2" />
Reject (Cancel Request)
</>
)}
</Button>
<Button
onClick={handleApprove}
disabled={!comments.trim() || submitting}
className="bg-green-600 hover:bg-green-700 text-white"
>
{submitting && actionType === 'approve' ? (
'Approving...'
) : (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Approve (Continue to Dept Lead)
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
{/* File Preview Modal - Matching DocumentsTab style */}
{previewDocument && (
<Dialog
open={!!previewDocument}
onOpenChange={() => setPreviewDocument(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">
{previewDocument.name}
</DialogTitle>
{previewDocument.type && (
<p className="text-xs sm:text-sm text-gray-500">
{previewDocument.type} {previewDocument.size && `${(previewDocument.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={() => {
const link = document.createElement('a');
link.href = previewDocument.url;
link.download = previewDocument.name;
link.click();
}}
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">
{previewLoading ? (
<div className="flex items-center justify-center h-full min-h-[70vh]">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-blue-600 mx-auto mb-2" />
<p className="text-sm text-gray-600">Loading preview...</p>
</div>
</div>
) : previewDocument.name.toLowerCase().endsWith('.pdf') || previewDocument.type?.includes('pdf') ? (
<div className="w-full h-full flex items-center justify-center">
<iframe
src={previewDocument.url}
className="w-full h-full rounded-lg border-0"
title={previewDocument.name}
style={{
minHeight: '70vh',
height: '100%'
}}
/>
</div>
) : previewDocument.name.match(/\.(jpg|jpeg|png|gif|webp)$/i) || previewDocument.type?.includes('image') ? (
<div className="flex items-center justify-center h-full">
<img
src={previewDocument.url}
alt={previewDocument.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain'
}}
className="rounded-lg shadow-lg"
/>
</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={() => {
const link = document.createElement('a');
link.href = previewDocument.url;
link.download = previewDocument.name;
link.click();
}}
className="gap-2"
>
<Download className="h-4 w-4" />
Download {previewDocument.name}
</Button>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)}
</Dialog>
);
}