clim approval versioning enhanced view detail for proposal and completion snapshot

This commit is contained in:
laxmanhalaki 2026-01-19 20:03:41 +05:30
parent a3a142d603
commit 66c33703e1
8 changed files with 1231 additions and 552 deletions

View File

@ -6,7 +6,7 @@ export interface DocumentData {
documentId: string; documentId: string;
name: string; name: string;
fileType: string; fileType: string;
size: string; size?: string;
sizeBytes?: number; sizeBytes?: number;
uploadedBy?: string; uploadedBy?: string;
uploadedAt: string; uploadedAt: string;
@ -48,7 +48,9 @@ export function DocumentCard({
{document.name} {document.name}
</p> </p>
<p className="text-xs text-gray-500" data-testid={`${testId}-metadata`}> <p className="text-xs text-gray-500" data-testid={`${testId}-metadata`}>
{document.size} Uploaded by {document.uploadedBy} on {formatDateTime(document.uploadedAt)} {document.size && <span>{document.size} </span>}
{document.uploadedBy && <span>Uploaded by {document.uploadedBy} on </span>}
{formatDateTime(document.uploadedAt)}
</p> </p>
</div> </div>
</div> </div>

View File

@ -10,7 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { TrendingUp, Clock, CheckCircle, CircleCheckBig, Upload, Mail, Download, Receipt, Activity, AlertTriangle, AlertOctagon, XCircle, History, ChevronDown, ChevronUp, RefreshCw, RotateCw } from 'lucide-react'; import { TrendingUp, Clock, CheckCircle, CircleCheckBig, Upload, Mail, Download, Receipt, Activity, AlertTriangle, AlertOctagon, XCircle, History, ChevronDown, ChevronUp, RefreshCw, RotateCw, Eye } from 'lucide-react';
import { formatDateTime, formatDateDDMMYYYY } from '@/utils/dateFormatter'; import { formatDateTime, formatDateDDMMYYYY } from '@/utils/dateFormatter';
import { formatHoursMinutes } from '@/utils/slaTracker'; import { formatHoursMinutes } from '@/utils/slaTracker';
import { import {
@ -21,7 +21,8 @@ import {
DealerCompletionDocumentsModal, DealerCompletionDocumentsModal,
CreditNoteSAPModal, CreditNoteSAPModal,
EmailNotificationTemplateModal, EmailNotificationTemplateModal,
DMSPushModal DMSPushModal,
SnapshotDetailsModal
// InitiatorActionModal - Removed, using direct buttons instead // InitiatorActionModal - Removed, using direct buttons instead
} from './modals'; } from './modals';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -35,6 +36,10 @@ interface DealerClaimWorkflowTabProps {
isInitiator: boolean; isInitiator: boolean;
onSkipApprover?: (data: any) => void; onSkipApprover?: (data: any) => void;
onRefresh?: () => void; onRefresh?: () => void;
documentPolicy: {
maxFileSizeMB: number;
allowedFileTypes: string[];
};
} }
interface WorkflowStep { interface WorkflowStep {
@ -67,6 +72,7 @@ interface WorkflowStep {
versionHistory?: { versionHistory?: {
current: any; current: any;
previous: any; previous: any;
all?: any[];
}; };
} }
@ -163,7 +169,8 @@ export function DealerClaimWorkflowTab({
user, user,
isInitiator, isInitiator,
onSkipApprover: _onSkipApprover, onSkipApprover: _onSkipApprover,
onRefresh onRefresh,
documentPolicy
}: DealerClaimWorkflowTabProps) { }: DealerClaimWorkflowTabProps) {
const [showProposalModal, setShowProposalModal] = useState(false); const [showProposalModal, setShowProposalModal] = useState(false);
const [showApprovalModal, setShowApprovalModal] = useState(false); const [showApprovalModal, setShowApprovalModal] = useState(false);
@ -179,6 +186,7 @@ export function DealerClaimWorkflowTab({
const [versionHistory, setVersionHistory] = useState<any[]>([]); const [versionHistory, setVersionHistory] = useState<any[]>([]);
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
const [expandedVersionSteps, setExpandedVersionSteps] = useState<Set<number>>(new Set()); const [expandedVersionSteps, setExpandedVersionSteps] = useState<Set<number>>(new Set());
const [viewSnapshot, setViewSnapshot] = useState<{data: any, type: 'PROPOSAL' | 'COMPLETION', title?: string} | null>(null);
// Load approval flows from real API // Load approval flows from real API
const [approvalFlow, setApprovalFlow] = useState<any[]>([]); const [approvalFlow, setApprovalFlow] = useState<any[]>([]);
@ -303,7 +311,11 @@ export function DealerClaimWorkflowTab({
if (request?.id || request?.requestId) { if (request?.id || request?.requestId) {
try { try {
const history = await getWorkflowHistory(request.id || request.requestId); const history = await getWorkflowHistory(request.id || request.requestId);
setVersionHistory(history); // Sort by createdAt descending to ensure most recent is at the top
const sortedHistory = [...(history || [])].sort((a: any, b: any) => {
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
setVersionHistory(sortedHistory);
} catch (error) { } catch (error) {
console.warn('Failed to load version history:', error); console.warn('Failed to load version history:', error);
} }
@ -444,12 +456,40 @@ export function DealerClaimWorkflowTab({
}); });
// Sort by version descending to get most recent first // Sort by version descending to get most recent first
const sortedVersions = [...stepVersions].sort((a, b) => b.version - a.version); // Prioritize APPROVE snapshots over WORKFLOW snapshots for the same version
// This ensures approval comments are shown instead of just workflow movement
const sortedVersions = [...stepVersions].sort((a, b) => {
// First sort by version (descending)
if (b.version !== a.version) {
return b.version - a.version;
}
// If same version, prioritize APPROVE over WORKFLOW
const aPriority = a.snapshotType === 'APPROVE' ? 1 : a.snapshotType === 'PROPOSAL' ? 2 : a.snapshotType === 'COMPLETION' ? 2 : 3;
const bPriority = b.snapshotType === 'APPROVE' ? 1 : b.snapshotType === 'PROPOSAL' ? 2 : b.snapshotType === 'COMPLETION' ? 2 : 3;
return aPriority - bPriority;
});
const current = sortedVersions.length > 0 ? sortedVersions[0] : null; // Filter out WORKFLOW snapshots if there's an APPROVE snapshot for the same level
const previous = sortedVersions.length > 1 ? sortedVersions[1] : null; // This ensures we show APPROVE snapshots (with comments) instead of WORKFLOW snapshots
const filteredVersions = sortedVersions.filter((version, _index, arr) => {
// If this is a WORKFLOW snapshot, check if there's an APPROVE snapshot with same or higher version
if (version.snapshotType === 'WORKFLOW') {
const hasApproveSnapshot = arr.some(v =>
v.snapshotType === 'APPROVE' &&
v.levelName === version.levelName &&
v.version >= version.version
);
// Only keep WORKFLOW snapshot if there's no APPROVE snapshot
return !hasApproveSnapshot;
}
// Keep all non-WORKFLOW snapshots
return true;
});
return { current, previous }; const current = filteredVersions.length > 0 ? filteredVersions[0] : null;
const previous = filteredVersions.length > 1 ? filteredVersions[1] : null;
return { current, previous, all: filteredVersions };
}; };
@ -939,6 +979,62 @@ export function DealerClaimWorkflowTab({
} }
}; };
// Handle proposal revision request
const handleProposalRevision = async (comments: string) => {
try {
if (!request?.id && !request?.requestId) {
throw new Error('Request ID not found');
}
const requestId = request.id || request.requestId;
// Get approval levels to find the initiator's step levelId dynamically
const details = await getWorkflowDetails(requestId);
const approvals = details?.approvalLevels || details?.approvals || [];
// Find the initiator's step by checking approverEmail or levelName
const initiatorEmail = (
(request as any)?.initiator?.email?.toLowerCase() ||
(request as any)?.initiatorEmail?.toLowerCase() ||
''
);
const step2Level = approvals.find((level: any) => {
const levelApproverEmail = (level.approverEmail || level.approver_email || '').toLowerCase();
const levelName = (level.levelName || level.level_name || '').toLowerCase();
const levelNumber = level.levelNumber || level.level_number;
// Check if this is the initiator's step
return (initiatorEmail && levelApproverEmail === initiatorEmail) ||
levelName.includes('requestor evaluation') ||
(levelName.includes('requestor') && levelName.includes('confirmation')) ||
// Fallback: if initiatorStepNumber was found earlier, use it
(levelNumber === initiatorStepNumber);
}) || approvals.find((level: any) =>
(level.levelNumber || level.level_number) === 2
); // Final fallback to level 2
if (!step2Level?.levelId && !step2Level?.level_id) {
throw new Error('Initiator approval level not found');
}
const levelId = step2Level.levelId || step2Level.level_id;
// Reject the initiator's step using real API with Revision Requested reason
// This will trigger the backend to return the workflow to the previous step (Dealer)
await rejectLevel(requestId, levelId, 'Revised Quotation Requested', comments);
// Activity is logged by backend approval service - no need to create work note
toast.success('Revision requested. Request returned to dealer.');
handleRefresh();
} catch (error: any) {
console.error('Failed to request revision:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to request revision. Please try again.';
toast.error(errorMessage);
throw error;
}
};
// Handle IO approval (Department Lead step - found dynamically) // Handle IO approval (Department Lead step - found dynamically)
const handleIOApproval = async (data: { const handleIOApproval = async (data: {
ioNumber: string; ioNumber: string;
@ -1440,7 +1536,7 @@ export function DealerClaimWorkflowTab({
)} )}
{/* Version History Section */} {/* Version History Section */}
{step.versionHistory && (step.versionHistory.current || step.versionHistory.previous) && ( {step.versionHistory && step.versionHistory.all && step.versionHistory.all.length > 0 && (
<div className="mt-3"> <div className="mt-3">
<Button <Button
variant="ghost" variant="ghost"
@ -1459,14 +1555,9 @@ export function DealerClaimWorkflowTab({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<History className="w-3.5 h-3.5" /> <History className="w-3.5 h-3.5" />
<span className="font-medium">Version History</span> <span className="font-medium">Version History</span>
{step.versionHistory.current && ( {step.versionHistory.all && step.versionHistory.all.length > 0 && (
<Badge className="bg-amber-100 text-amber-800 text-[10px] px-1.5 py-0"> <Badge className="bg-amber-100 text-amber-800 text-[10px] px-1.5 py-0">
v{step.versionHistory.current.version} {step.versionHistory.all.length} Versions
</Badge>
)}
{step.versionHistory.previous && (
<Badge className="bg-gray-100 text-gray-600 text-[10px] px-1.5 py-0">
v{step.versionHistory.previous.version}
</Badge> </Badge>
)} )}
</div> </div>
@ -1477,233 +1568,143 @@ export function DealerClaimWorkflowTab({
)} )}
</Button> </Button>
{expandedVersionSteps.has(step.step) && ( {expandedVersionSteps.has(step.step) && step.versionHistory.all && (
<div className="mt-2 space-y-3 p-3 bg-amber-50/50 rounded-lg border border-amber-200"> <div className="mt-2 space-y-3 p-3 bg-amber-50/50 rounded-lg border border-amber-200 text-left">
{/* Current Version */} {step.versionHistory.all.map((version: any, vIndex: number) => (
{step.versionHistory.current && ( <div key={vIndex} className={`space-y-2 ${vIndex > 0 ? 'pt-2 border-t border-amber-200' : ''}`}>
<div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge className="bg-amber-500 text-white text-[10px] px-2 py-0.5"> <Badge className={`${vIndex === 0 ? 'bg-amber-500' : 'bg-gray-400'} text-white text-[10px] px-2 py-0.5`}>
Current: v{step.versionHistory.current.version} {vIndex === 0 ? 'Current' : 'Previous'}: v{version.version}
</Badge>
<span className="text-[10px] text-amber-700 font-medium">
{formatDateSafe(step.versionHistory.current.createdAt)}
</span>
</div>
</div>
<p className="text-xs text-gray-700 font-medium">
{step.versionHistory.current.changeReason || 'Version Update'}
</p>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-blue-100 flex items-center justify-center">
<span className="text-[8px] font-bold text-blue-600">
{step.versionHistory.current.changer?.displayName?.charAt(0) || 'U'}
</span>
</div>
<span className="text-[10px] text-gray-600">
By {step.versionHistory.current.changer?.displayName || step.versionHistory.current.changer?.email || 'Unknown User'}
</span>
</div>
{/* Show snapshot data if available - JSONB structure */}
{step.versionHistory.current.snapshotType === 'PROPOSAL' && step.versionHistory.current.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-amber-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1">Proposal Snapshot:</p>
{step.versionHistory.current.snapshotData.documentUrl && (
<p className="text-[10px] text-blue-600 mb-1">
<a href={step.versionHistory.current.snapshotData.documentUrl} target="_blank" rel="noopener noreferrer" className="underline">
View Document
</a>
</p>
)}
<p className="text-[10px] text-gray-600">
Budget: {Number(step.versionHistory.current.snapshotData.totalBudget || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
{step.versionHistory.current.snapshotData.comments && (
<p className="text-[10px] text-gray-600 mt-1">
Comments: {step.versionHistory.current.snapshotData.comments.substring(0, 100)}
{step.versionHistory.current.snapshotData.comments.length > 100 ? '...' : ''}
</p>
)}
{step.versionHistory.current.snapshotData.costItems && step.versionHistory.current.snapshotData.costItems.length > 0 && (
<p className="text-[10px] text-gray-500 mt-1">
{step.versionHistory.current.snapshotData.costItems.length} cost item(s)
</p>
)}
</div>
)}
{step.versionHistory.current.snapshotType === 'INTERNAL_ORDER' && step.versionHistory.current.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-amber-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1">IO Block Snapshot:</p>
<p className="text-[10px] text-gray-600">
IO Number: {step.versionHistory.current.snapshotData.ioNumber || 'N/A'}
</p>
<p className="text-[10px] text-gray-600">
Blocked Amount: {Number(step.versionHistory.current.snapshotData.blockedAmount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
{step.versionHistory.current.snapshotData.sapDocumentNumber && (
<p className="text-[10px] text-gray-600">
SAP Doc: {step.versionHistory.current.snapshotData.sapDocumentNumber}
</p>
)}
</div>
)}
{step.versionHistory.current.snapshotType === 'COMPLETION' && step.versionHistory.current.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-amber-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1">Completion Snapshot:</p>
{step.versionHistory.current.snapshotData.documentUrl && (
<p className="text-[10px] text-blue-600 mb-1">
<a href={step.versionHistory.current.snapshotData.documentUrl} target="_blank" rel="noopener noreferrer" className="underline">
View Document
</a>
</p>
)}
<p className="text-[10px] text-gray-600">
Total Expenses: {Number(step.versionHistory.current.snapshotData.totalExpenses || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
{step.versionHistory.current.snapshotData.comments && (
<p className="text-[10px] text-gray-600 mt-1">
Comments: {step.versionHistory.current.snapshotData.comments.substring(0, 100)}
{step.versionHistory.current.snapshotData.comments.length > 100 ? '...' : ''}
</p>
)}
{step.versionHistory.current.snapshotData.expenses && step.versionHistory.current.snapshotData.expenses.length > 0 && (
<p className="text-[10px] text-gray-500 mt-1">
{step.versionHistory.current.snapshotData.expenses.length} expense item(s)
</p>
)}
</div>
)}
{step.versionHistory.current.snapshotType === 'APPROVE' && step.versionHistory.current.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-amber-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1">
{step.versionHistory.current.snapshotData.action === 'APPROVE' ? 'Approval' : 'Rejection'} Snapshot:
</p>
<p className="text-[10px] text-gray-600">
By: {step.versionHistory.current.snapshotData.approverName || step.versionHistory.current.snapshotData.approverEmail || 'Unknown'}
</p>
{step.versionHistory.current.snapshotData.comments && (
<p className="text-[10px] text-gray-600 mt-1">
Comments: {step.versionHistory.current.snapshotData.comments.substring(0, 100)}
{step.versionHistory.current.snapshotData.comments.length > 100 ? '...' : ''}
</p>
)}
{step.versionHistory.current.snapshotData.rejectionReason && (
<p className="text-[10px] text-red-600 mt-1">
Rejection Reason: {step.versionHistory.current.snapshotData.rejectionReason.substring(0, 100)}
{step.versionHistory.current.snapshotData.rejectionReason.length > 100 ? '...' : ''}
</p>
)}
</div>
)}
</div>
)}
{/* Previous Version */}
{step.versionHistory.previous && (
<div className="space-y-2 pt-2 border-t border-amber-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge className="bg-gray-400 text-white text-[10px] px-2 py-0.5">
Previous: v{step.versionHistory.previous.version}
</Badge> </Badge>
<span className="text-[10px] text-gray-600 font-medium"> <span className="text-[10px] text-gray-600 font-medium">
{formatDateSafe(step.versionHistory.previous.createdAt)} {formatDateSafe(version.createdAt)}
</span> </span>
</div> </div>
</div> </div>
<p className="text-xs text-gray-700 font-medium"> <p className="text-xs text-gray-700 font-medium">
{step.versionHistory.previous.changeReason || 'Version Update'} {version.changeReason || 'Version Update'}
</p> </p>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-gray-100 flex items-center justify-center"> <div className={`w-3 h-3 rounded-full ${vIndex === 0 ? 'bg-blue-100' : 'bg-gray-100'} flex items-center justify-center`}>
<span className="text-[8px] font-bold text-gray-600"> <span className={`text-[8px] font-bold ${vIndex === 0 ? 'text-blue-600' : 'text-gray-600'}`}>
{step.versionHistory.previous.changer?.displayName?.charAt(0) || 'U'} {version.changer?.displayName?.charAt(0) || 'U'}
</span> </span>
</div> </div>
<span className="text-[10px] text-gray-600"> <span className="text-[10px] text-gray-600">
By {step.versionHistory.previous.changer?.displayName || step.versionHistory.previous.changer?.email || 'Unknown User'} By {version.changer?.displayName || version.changer?.email || 'Unknown User'}
</span> </span>
</div> </div>
{/* Show snapshot data if available - JSONB structure */} {/* Show snapshot data if available - JSONB structure */}
{step.versionHistory.previous.snapshotType === 'PROPOSAL' && step.versionHistory.previous.snapshotData && ( {version.snapshotType === 'PROPOSAL' && version.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-gray-200"> <div className="mt-2 p-2 bg-white rounded border border-amber-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1">Proposal Snapshot:</p> <div className="flex justify-between items-start mb-2">
{step.versionHistory.previous.snapshotData.documentUrl && ( <div>
<p className="text-[10px] text-blue-600 mb-1"> <p className="text-[10px] font-semibold text-gray-700">Proposal Snapshot</p>
<a href={step.versionHistory.previous.snapshotData.documentUrl} target="_blank" rel="noopener noreferrer" className="underline">
View Document
</a>
</p>
)}
<p className="text-[10px] text-gray-600"> <p className="text-[10px] text-gray-600">
Budget: {Number(step.versionHistory.previous.snapshotData.totalBudget || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} Budget: {Number(version.snapshotData.totalBudget || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p> </p>
{step.versionHistory.previous.snapshotData.comments && ( </div>
<p className="text-[10px] text-gray-600 mt-1"> <button
Comments: {step.versionHistory.previous.snapshotData.comments.substring(0, 100)} className="text-[10px] text-blue-600 hover:text-blue-800 hover:underline font-medium flex items-center gap-1 transition-colors"
{step.versionHistory.previous.snapshotData.comments.length > 100 ? '...' : ''} onClick={() => setViewSnapshot({
data: version.snapshotData,
type: 'PROPOSAL',
title: `Proposal (v${version.version})`
})}
>
<Eye className="w-3 h-3" />
View Details
</button>
</div>
{version.snapshotData.comments && (
<p className="text-[10px] text-gray-600 mt-1 line-clamp-2">
Comments: {version.snapshotData.comments}
</p> </p>
)} )}
</div> </div>
)} )}
{step.versionHistory.previous.snapshotType === 'INTERNAL_ORDER' && step.versionHistory.previous.snapshotData && ( {version.snapshotType === 'INTERNAL_ORDER' && version.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-gray-200"> <div className="mt-2 p-2 bg-white rounded border border-amber-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1">IO Block Snapshot:</p> <p className="text-[10px] font-semibold text-gray-700 mb-1">IO Block Snapshot:</p>
<p className="text-[10px] text-gray-600"> <p className="text-[10px] text-gray-600">
IO Number: {step.versionHistory.previous.snapshotData.ioNumber || 'N/A'} IO Number: {version.snapshotData.ioNumber || 'N/A'}
</p> </p>
<p className="text-[10px] text-gray-600"> <p className="text-[10px] text-gray-600">
Blocked Amount: {Number(step.versionHistory.previous.snapshotData.blockedAmount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} Blocked Amount: {Number(version.snapshotData.blockedAmount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p> </p>
</div> {version.snapshotData.sapDocumentNumber && (
)}
{step.versionHistory.previous.snapshotType === 'COMPLETION' && step.versionHistory.previous.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-gray-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1">Completion Snapshot:</p>
{step.versionHistory.previous.snapshotData.documentUrl && (
<p className="text-[10px] text-blue-600 mb-1">
<a href={step.versionHistory.previous.snapshotData.documentUrl} target="_blank" rel="noopener noreferrer" className="underline">
View Document
</a>
</p>
)}
<p className="text-[10px] text-gray-600"> <p className="text-[10px] text-gray-600">
Total Expenses: {Number(step.versionHistory.previous.snapshotData.totalExpenses || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} SAP Doc: {version.snapshotData.sapDocumentNumber}
</p>
{step.versionHistory.previous.snapshotData.comments && (
<p className="text-[10px] text-gray-600 mt-1">
Comments: {step.versionHistory.previous.snapshotData.comments.substring(0, 100)}
{step.versionHistory.previous.snapshotData.comments.length > 100 ? '...' : ''}
</p> </p>
)} )}
</div> </div>
)} )}
{step.versionHistory.previous.snapshotType === 'APPROVE' && step.versionHistory.previous.snapshotData && ( {version.snapshotType === 'COMPLETION' && version.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-gray-200"> <div className="mt-2 p-2 bg-white rounded border border-amber-200">
<div className="flex justify-between items-start mb-2">
<div>
<p className="text-[10px] font-semibold text-gray-700">Completion Snapshot</p>
<p className="text-[10px] text-gray-600">
Total: {Number(version.snapshotData.totalExpenses || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<button
className="text-[10px] text-blue-600 hover:text-blue-800 hover:underline font-medium flex items-center gap-1 transition-colors"
onClick={() => setViewSnapshot({
data: version.snapshotData,
type: 'COMPLETION',
title: `Completion (v${version.version})`
})}
>
<Eye className="w-3 h-3" />
View Details
</button>
</div>
{version.snapshotData.comments && (
<p className="text-[10px] text-gray-600 mt-1 line-clamp-2">
Comments: {version.snapshotData.comments}
</p>
)}
</div>
)}
{version.snapshotType === 'APPROVE' && version.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-amber-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1"> <p className="text-[10px] font-semibold text-gray-700 mb-1">
{step.versionHistory.previous.snapshotData.action === 'APPROVE' ? 'Approval' : 'Rejection'} Snapshot: {version.snapshotData.action === 'APPROVE' ? 'Approval' : 'Rejection'} Snapshot:
</p> </p>
<p className="text-[10px] text-gray-600"> <p className="text-[10px] text-gray-600">
By: {step.versionHistory.previous.snapshotData.approverName || step.versionHistory.previous.snapshotData.approverEmail || 'Unknown'} By: {version.snapshotData.approverName || version.snapshotData.approverEmail || 'Unknown'}
</p> </p>
{step.versionHistory.previous.snapshotData.comments && ( {version.snapshotData.comments && (
<p className="text-[10px] text-gray-600 mt-1"> <p className="text-[10px] text-gray-600 mt-1">
Comments: {step.versionHistory.previous.snapshotData.comments.substring(0, 100)} Comments: {version.snapshotData.comments.substring(0, 100)}
{step.versionHistory.previous.snapshotData.comments.length > 100 ? '...' : ''} {version.snapshotData.comments.length > 100 ? '...' : ''}
</p> </p>
)} )}
{step.versionHistory.previous.snapshotData.rejectionReason && ( {version.snapshotData.rejectionReason && (
<p className="text-[10px] text-red-600 mt-1"> <p className="text-[10px] text-red-600 mt-1">
Rejection Reason: {step.versionHistory.previous.snapshotData.rejectionReason.substring(0, 100)} Rejection Reason: {version.snapshotData.rejectionReason.substring(0, 100)}
{step.versionHistory.previous.snapshotData.rejectionReason.length > 100 ? '...' : ''} {version.snapshotData.rejectionReason.length > 100 ? '...' : ''}
</p> </p>
)} )}
</div> </div>
)} )}
{version.snapshotType === 'WORKFLOW' && version.snapshotData && version.snapshotData.comments && (
<div className="mt-2 p-2 bg-white rounded border border-amber-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1">Approval Comment:</p>
<p className="text-[10px] text-gray-600">
{version.snapshotData.comments.substring(0, 100)}
{version.snapshotData.comments.length > 100 ? '...' : ''}
</p>
</div> </div>
)} )}
</div> </div>
))}
</div>
)} )}
</div> </div>
)} )}
@ -2252,6 +2253,8 @@ export function DealerClaimWorkflowTab({
dealerName={dealerName} dealerName={dealerName}
activityName={activityName} activityName={activityName}
requestId={request?.id || request?.requestId} requestId={request?.id || request?.requestId}
previousProposalData={versionHistory?.find(v => v.snapshotType === 'PROPOSAL')?.snapshotData}
documentPolicy={documentPolicy}
/> />
{/* Initiator Proposal Approval Modal */} {/* Initiator Proposal Approval Modal */}
@ -2262,11 +2265,19 @@ export function DealerClaimWorkflowTab({
}} }}
onApprove={handleProposalApprove} onApprove={handleProposalApprove}
onReject={handleProposalReject} onReject={handleProposalReject}
onRequestRevision={handleProposalRevision}
proposalData={proposalData} proposalData={proposalData}
dealerName={dealerName} dealerName={dealerName}
activityName={activityName} activityName={activityName}
requestId={request?.id || request?.requestId} requestId={request?.id || request?.requestId}
request={request} request={request}
previousProposalData={(() => {
const proposalSnapshots = versionHistory?.filter(v => v.snapshotType === 'PROPOSAL') || [];
// Since history is sorted descending (most recent first):
// proposalSnapshots[0] is the current proposal being reviewed
// proposalSnapshots[1] is the previous proposal (last iteration - 1)
return proposalSnapshots.length > 1 ? proposalSnapshots[1].snapshotData : null;
})()}
/> />
{/* Dept Lead IO Approval Modal */} {/* Dept Lead IO Approval Modal */}
@ -2290,6 +2301,7 @@ export function DealerClaimWorkflowTab({
dealerName={dealerName} dealerName={dealerName}
activityName={activityName} activityName={activityName}
requestId={request?.id || request?.requestId} requestId={request?.id || request?.requestId}
documentPolicy={documentPolicy}
/> />
{/* DMS Push Modal */} {/* DMS Push Modal */}
@ -2525,11 +2537,21 @@ export function DealerClaimWorkflowTab({
<div className="mt-2 p-2 bg-white rounded border border-amber-200 text-[10px]"> <div className="mt-2 p-2 bg-white rounded border border-amber-200 text-[10px]">
<p className="font-semibold text-gray-700 mb-1">Proposal:</p> <p className="font-semibold text-gray-700 mb-1">Proposal:</p>
{item.snapshotData.documentUrl && ( {item.snapshotData.documentUrl && (
<p className="text-blue-600 mb-1"> <div className="flex items-center gap-2 mb-1">
<a href={item.snapshotData.documentUrl} target="_blank" rel="noopener noreferrer" className="underline"> <Button
View Document variant="link"
</a> size="sm"
</p> className="h-auto p-0 text-blue-600 hover:text-blue-700 font-small flex items-center gap-1"
onClick={() => setViewSnapshot({
data: item.snapshotData,
type: 'PROPOSAL',
title: `Historical Proposal (Version ${item.version})`
})}
>
View Details
<Eye className="w-2 h-2" />
</Button>
</div>
)} )}
<p className="text-gray-600">Budget: {Number(item.snapshotData.totalBudget || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</p> <p className="text-gray-600">Budget: {Number(item.snapshotData.totalBudget || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</p>
{item.snapshotData.comments && ( {item.snapshotData.comments && (
@ -2541,11 +2563,21 @@ export function DealerClaimWorkflowTab({
<div className="mt-2 p-2 bg-white rounded border border-amber-200 text-[10px]"> <div className="mt-2 p-2 bg-white rounded border border-amber-200 text-[10px]">
<p className="font-semibold text-gray-700 mb-1">Completion:</p> <p className="font-semibold text-gray-700 mb-1">Completion:</p>
{item.snapshotData.documentUrl && ( {item.snapshotData.documentUrl && (
<p className="text-blue-600 mb-1"> <div className="flex items-center gap-2 mb-1">
<a href={item.snapshotData.documentUrl} target="_blank" rel="noopener noreferrer" className="underline"> <Button
View Document variant="link"
</a> size="sm"
</p> className="h-auto p-0 text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
onClick={() => setViewSnapshot({
data: item.snapshotData,
type: 'COMPLETION',
title: `Historical Completion (Version ${item.version})`
})}
>
View Details
<Eye className="w-3 h-3" />
</Button>
</div>
)} )}
<p className="text-gray-600">Total Expenses: {Number(item.snapshotData.totalExpenses || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</p> <p className="text-gray-600">Total Expenses: {Number(item.snapshotData.totalExpenses || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</p>
{item.snapshotData.comments && ( {item.snapshotData.comments && (
@ -2597,6 +2629,14 @@ export function DealerClaimWorkflowTab({
)} )}
</Card> </Card>
)} )}
<SnapshotDetailsModal
isOpen={!!viewSnapshot}
onClose={() => setViewSnapshot(null)}
snapshot={viewSnapshot?.data}
type={viewSnapshot?.type || 'PROPOSAL'}
title={viewSnapshot?.title}
/>
</> </>
); );
} }

View File

@ -47,6 +47,10 @@ interface DealerCompletionDocumentsModalProps {
dealerName?: string; dealerName?: string;
activityName?: string; activityName?: string;
requestId?: string; requestId?: string;
documentPolicy: {
maxFileSizeMB: number;
allowedFileTypes: string[];
};
} }
export function DealerCompletionDocumentsModal({ export function DealerCompletionDocumentsModal({
@ -56,6 +60,7 @@ export function DealerCompletionDocumentsModal({
dealerName = 'Jaipur Royal Enfield', dealerName = 'Jaipur Royal Enfield',
activityName = 'Activity', activityName = 'Activity',
requestId: _requestId, requestId: _requestId,
documentPolicy,
}: DealerCompletionDocumentsModalProps) { }: DealerCompletionDocumentsModalProps) {
const [activityCompletionDate, setActivityCompletionDate] = useState(''); const [activityCompletionDate, setActivityCompletionDate] = useState('');
const [numberOfParticipants, setNumberOfParticipants] = useState(''); const [numberOfParticipants, setNumberOfParticipants] = useState('');
@ -164,16 +169,40 @@ export function DealerCompletionDocumentsModal({
const handleCompletionDocsChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleCompletionDocsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
if (files.length > 0) { if (files.length > 0) {
// Validate file types const validFiles: File[] = [];
const allowedTypes = ['.pdf', '.doc', '.docx', '.zip', '.rar']; const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
const invalidFiles = files.filter( const allowedExts = ['.pdf', '.doc', '.docx', '.zip', '.rar'];
(file) => !allowedTypes.some((ext) => file.name.toLowerCase().endsWith(ext))
); files.forEach(file => {
if (invalidFiles.length > 0) { const fileExt = '.' + file.name.split('.').pop()?.toLowerCase();
toast.error('Please upload PDF, DOC, DOCX, ZIP, or RAR files only'); 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; return;
} }
setCompletionDocuments([...completionDocuments, ...files]);
// 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 = '';
} }
}; };
@ -184,15 +213,38 @@ export function DealerCompletionDocumentsModal({
const handlePhotosChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handlePhotosChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
if (files.length > 0) { if (files.length > 0) {
// Validate image files const validFiles: File[] = [];
const invalidFiles = files.filter( const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
(file) => !file.type.startsWith('image/')
); files.forEach(file => {
if (invalidFiles.length > 0) { const simpleExt = file.name.split('.').pop()?.toLowerCase() || '';
toast.error('Please upload image files only (JPG, PNG, etc.)');
// 1. Check file size
if (file.size > maxSizeBytes) {
toast.error(`Photo "${file.name}" exceeds ${documentPolicy.maxFileSizeMB}MB limit.`);
return; return;
} }
setActivityPhotos([...activityPhotos, ...files]);
// 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 = '';
} }
}; };
@ -203,16 +255,40 @@ export function DealerCompletionDocumentsModal({
const handleInvoicesChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInvoicesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
if (files.length > 0) { if (files.length > 0) {
// Validate file types const validFiles: File[] = [];
const allowedTypes = ['.pdf', '.jpg', '.jpeg', '.png']; const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
const invalidFiles = files.filter( const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png'];
(file) => !allowedTypes.some((ext) => file.name.toLowerCase().endsWith(ext))
); files.forEach(file => {
if (invalidFiles.length > 0) { const fileExt = '.' + file.name.split('.').pop()?.toLowerCase();
toast.error('Please upload PDF, JPG, or PNG files only'); 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; return;
} }
setInvoicesReceipts([...invoicesReceipts, ...files]);
// 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 = '';
} }
}; };
@ -223,13 +299,32 @@ export function DealerCompletionDocumentsModal({
const handleAttendanceChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleAttendanceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
// Validate file types const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
const allowedTypes = ['.pdf', '.xlsx', '.xls', '.csv']; const allowedExts = ['.pdf', '.xlsx', '.xls', '.csv'];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); const fileExt = '.' + file.name.split('.').pop()?.toLowerCase();
if (!allowedTypes.includes(fileExtension)) { const simpleExt = file.name.split('.').pop()?.toLowerCase() || '';
toast.error('Please upload PDF, Excel, or CSV files only');
// 1. Check file size
if (file.size > maxSizeBytes) {
toast.error(`Attendance file exceeds ${documentPolicy.maxFileSizeMB}MB limit.`);
if (attendanceInputRef.current) attendanceInputRef.current.value = '';
return; 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); setAttendanceSheet(file);
} }
}; };
@ -438,7 +533,7 @@ export function DealerCompletionDocumentsModal({
ref={completionDocsInputRef} ref={completionDocsInputRef}
type="file" type="file"
multiple multiple
accept=".pdf,.doc,.docx,.zip,.rar" accept={['.pdf', '.doc', '.docx', '.zip', '.rar'].filter(ext => documentPolicy.allowedFileTypes.includes(ext.replace('.', ''))).join(',')}
className="hidden" className="hidden"
id="completionDocs" id="completionDocs"
onChange={handleCompletionDocsChange} onChange={handleCompletionDocsChange}
@ -463,8 +558,9 @@ export function DealerCompletionDocumentsModal({
<> <>
<Upload className="w-8 h-8 text-gray-400" /> <Upload className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600">
Click to upload documents (PDF, DOC, ZIP - multiple files allowed) Click to upload documents (Max {documentPolicy.maxFileSizeMB}MB)
</span> </span>
<p className="text-[10px] text-gray-400">PDF, DOC, ZIP allowed</p>
</> </>
)} )}
</label> </label>
@ -543,7 +639,7 @@ export function DealerCompletionDocumentsModal({
ref={photosInputRef} ref={photosInputRef}
type="file" type="file"
multiple multiple
accept="image/*" accept={['.jpg', '.jpeg', '.png', '.gif', '.webp'].filter(ext => documentPolicy.allowedFileTypes.includes(ext.replace('.', ''))).join(',')}
className="hidden" className="hidden"
id="completionPhotos" id="completionPhotos"
onChange={handlePhotosChange} onChange={handlePhotosChange}
@ -659,7 +755,7 @@ export function DealerCompletionDocumentsModal({
ref={invoicesInputRef} ref={invoicesInputRef}
type="file" type="file"
multiple multiple
accept=".pdf,.jpg,.jpeg,.png" accept={['.pdf', '.jpg', '.jpeg', '.png'].filter(ext => documentPolicy.allowedFileTypes.includes(ext.replace('.', ''))).join(',')}
className="hidden" className="hidden"
id="invoiceReceipts" id="invoiceReceipts"
onChange={handleInvoicesChange} onChange={handleInvoicesChange}
@ -762,7 +858,7 @@ export function DealerCompletionDocumentsModal({
<input <input
ref={attendanceInputRef} ref={attendanceInputRef}
type="file" type="file"
accept=".pdf,.xlsx,.xls,.csv" accept={['.pdf', '.xlsx', '.xls', '.csv'].filter(ext => documentPolicy.allowedFileTypes.includes(ext.replace('.', ''))).join(',')}
className="hidden" className="hidden"
id="attendanceDoc" id="attendanceDoc"
onChange={handleAttendanceChange} onChange={handleAttendanceChange}

View File

@ -19,8 +19,11 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { CustomDatePicker } from '@/components/ui/date-picker'; import { CustomDatePicker } from '@/components/ui/date-picker';
import { Upload, Plus, X, Calendar, IndianRupee, CircleAlert, CheckCircle2, FileText, Eye, Download } from 'lucide-react'; import { Upload, Plus, Minus, X, Calendar, IndianRupee, CircleAlert, CheckCircle2, FileText, Eye, Download } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { DocumentCard } from '@/components/workflow/DocumentUpload/DocumentCard';
import { FilePreview } from '@/components/common/FilePreview/FilePreview';
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
import '@/components/common/FilePreview/FilePreview.css'; import '@/components/common/FilePreview/FilePreview.css';
import './DealerProposalModal.css'; import './DealerProposalModal.css';
@ -43,6 +46,11 @@ interface DealerProposalSubmissionModalProps {
dealerName?: string; dealerName?: string;
activityName?: string; activityName?: string;
requestId?: string; requestId?: string;
previousProposalData?: any;
documentPolicy: {
maxFileSizeMB: number;
allowedFileTypes: string[];
};
} }
export function DealerProposalSubmissionModal({ export function DealerProposalSubmissionModal({
@ -52,6 +60,8 @@ export function DealerProposalSubmissionModal({
dealerName = 'Jaipur Royal Enfield', dealerName = 'Jaipur Royal Enfield',
activityName = 'Activity', activityName = 'Activity',
requestId: _requestId, requestId: _requestId,
previousProposalData,
documentPolicy,
}: DealerProposalSubmissionModalProps) { }: DealerProposalSubmissionModalProps) {
const [proposalDocument, setProposalDocument] = useState<File | null>(null); const [proposalDocument, setProposalDocument] = useState<File | null>(null);
const [costItems, setCostItems] = useState<CostItem[]>([ const [costItems, setCostItems] = useState<CostItem[]>([
@ -63,49 +73,87 @@ export function DealerProposalSubmissionModal({
const [otherDocuments, setOtherDocuments] = useState<File[]>([]); const [otherDocuments, setOtherDocuments] = useState<File[]>([]);
const [dealerComments, setDealerComments] = useState(''); const [dealerComments, setDealerComments] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [previewFile, setPreviewFile] = useState<{ file: File; url: string } | null>(null); const [previewDoc, setPreviewDoc] = useState<{
fileName: string;
fileType: string;
documentId: string;
fileUrl?: string;
fileSize?: number;
} | null>(null);
const [showPreviousProposal, setShowPreviousProposal] = useState(false);
const proposalDocInputRef = useRef<HTMLInputElement>(null); const proposalDocInputRef = useRef<HTMLInputElement>(null);
const otherDocsInputRef = useRef<HTMLInputElement>(null); const otherDocsInputRef = useRef<HTMLInputElement>(null);
// Helper function to check if file can be previewed // Helper function to check if file can be previewed
const canPreview = (fileName: string): boolean => {
if (!fileName) return false;
const name = fileName.toLowerCase();
return name.endsWith('.pdf') ||
!!name.match(/\.(jpg|jpeg|png|gif|webp)$/i);
};
const canPreviewFile = (file: File): boolean => { const canPreviewFile = (file: File): boolean => {
const type = file.type.toLowerCase(); return canPreview(file.name);
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 // Cleanup object URLs when component unmounts or file changes
useEffect(() => { useEffect(() => {
return () => { return () => {
if (previewFile?.url) { if (previewDoc?.fileUrl && previewDoc.fileUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewFile.url); URL.revokeObjectURL(previewDoc.fileUrl);
} }
}; };
}, [previewFile]); }, [previewDoc]);
// Handle file preview - instant preview using object URL // Handle manual file preview (for local files)
const handlePreviewFile = (file: File) => { const handlePreviewFile = (file: File) => {
if (!canPreviewFile(file)) { if (!canPreviewFile(file)) {
toast.error('Preview is only available for images and PDF files'); toast.error('Preview is only available for images and PDF files');
return; return;
} }
// Cleanup previous preview URL // Cleanup previous preview URL if it was a blob
if (previewFile?.url) { if (previewDoc?.fileUrl && previewDoc.fileUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewFile.url); URL.revokeObjectURL(previewDoc.fileUrl);
} }
// Create object URL immediately for instant preview // Create blob URL for local file
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
setPreviewFile({ file, url }); setPreviewDoc({
fileName: file.name,
fileType: file.type,
documentId: '',
fileUrl: url,
fileSize: file.size
});
};
// Handle preview for existing Documents (with storageUrl/documentId)
const handlePreviewExisting = (doc: any) => {
const fileName = doc.originalFileName || doc.fileName || doc.name || 'Document';
const documentId = doc.documentId || doc.id || '';
const fileType = fileName.toLowerCase().endsWith('.pdf') ? 'application/pdf' : 'image/jpeg';
let fileUrl = '';
if (documentId) {
fileUrl = getDocumentPreviewUrl(documentId);
} else {
fileUrl = doc.storageUrl || doc.documentUrl || '';
if (fileUrl && !fileUrl.startsWith('http')) {
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({
fileName,
fileType,
documentId,
fileUrl
});
}; };
// Handle download file (for non-previewable files) // Handle download file (for non-previewable files)
@ -141,20 +189,57 @@ export function DealerProposalSubmissionModal({
const handleProposalDocChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleProposalDocChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
// Validate file type // 1. Check file size
const allowedTypes = ['.pdf', '.doc', '.docx']; const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); if (file.size > maxSizeBytes) {
if (!allowedTypes.includes(fileExtension)) { toast.error(`File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`);
toast.error('Please upload a PDF, DOC, or DOCX file'); if (proposalDocInputRef.current) proposalDocInputRef.current.value = '';
return; return;
} }
// 2. Validate file type (User requested: Keep strictly to pdf, doc, docx + Intersection with system policy)
const hardcodedAllowed = ['.pdf', '.doc', '.docx'];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
const simpleExt = file.name.split('.').pop()?.toLowerCase() || '';
if (!hardcodedAllowed.includes(fileExtension) || !documentPolicy.allowedFileTypes.includes(simpleExt)) {
toast.error('Please upload a valid PDF, DOC, or DOCX file as per system policy');
if (proposalDocInputRef.current) proposalDocInputRef.current.value = '';
return;
}
setProposalDocument(file); setProposalDocument(file);
} }
}; };
const handleOtherDocsChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleOtherDocsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
setOtherDocuments(prev => [...prev, ...files]); const validFiles: File[] = [];
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
files.forEach(file => {
// 1. Check file size
if (file.size > maxSizeBytes) {
toast.error(`"${file.name}" exceeds ${documentPolicy.maxFileSizeMB}MB limit and was not added.`);
return;
}
// 2. Check file type
const fileExtension = file.name.split('.').pop()?.toLowerCase() || '';
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
toast.error(`"${file.name}" has an unsupported file type and was not added.`);
return;
}
validFiles.push(file);
});
if (validFiles.length > 0) {
setOtherDocuments(prev => [...prev, ...validFiles]);
}
// Reset input so searching the same file again triggers change event
if (otherDocsInputRef.current) otherDocsInputRef.current.value = '';
}; };
const handleAddCostItem = () => { const handleAddCostItem = () => {
@ -220,11 +305,11 @@ export function DealerProposalSubmissionModal({
}; };
const handleReset = () => { const handleReset = () => {
// Cleanup preview URL if exists // Cleanup preview URL if exists and it's a blob
if (previewFile?.url) { if (previewDoc?.fileUrl && previewDoc.fileUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewFile.url); URL.revokeObjectURL(previewDoc.fileUrl);
} }
setPreviewFile(null); setPreviewDoc(null);
setProposalDocument(null); setProposalDocument(null);
setCostItems([{ id: '1', description: '', amount: 0 }]); setCostItems([{ id: '1', description: '', amount: 0 }]);
setTimelineMode('date'); setTimelineMode('date');
@ -245,7 +330,6 @@ export function DealerProposalSubmissionModal({
// Get minimum date (today) // Get minimum date (today)
const minDate = new Date().toISOString().split('T')[0]; const minDate = new Date().toISOString().split('T')[0];
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col"> <DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col">
@ -272,7 +356,157 @@ export function DealerProposalSubmissionModal({
</div> </div>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3 lg:py-4"> <div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 py-3 lg:py-4">
{/* Previous Proposal Reference Section */}
{previousProposalData && (
<div className="mb-6 mx-1">
<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</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">
{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">
{canPreview(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>
)}
{/* 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={canPreview(doc.originalFileName || doc.fileName || doc.name || '') ? () => handlePreviewExisting(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>
)}
</div>
{/* Previous Cost Breakup (handling both costBreakup and costItems) */}
{(previousProposalData.costItems || previousProposalData.costBreakup) && (previousProposalData.costItems || previousProposalData.costBreakup).length > 0 && (
<div className="mt-2">
<p className="text-xs font-semibold text-gray-700 mb-2">Previous Cost Breakdown:</p>
<div className="border rounded-md overflow-hidden text-xs">
<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">
<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>
)}
{/* Previous Comments */}
{(previousProposalData.comments || previousProposalData.dealerComments) && (
<div>
<p className="text-xs font-semibold text-gray-700 mb-1">Previous Comments:</p>
<div className="bg-white border rounded p-2 text-xs text-gray-600 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"> <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 */} {/* Left Column - Documents */}
<div className="space-y-4 lg:space-y-4 flex flex-col"> <div className="space-y-4 lg:space-y-4 flex flex-col">
@ -299,7 +533,7 @@ export function DealerProposalSubmissionModal({
<input <input
ref={proposalDocInputRef} ref={proposalDocInputRef}
type="file" type="file"
accept=".pdf,.doc,.docx" accept={['.pdf', '.doc', '.docx'].filter(ext => documentPolicy.allowedFileTypes.includes(ext.replace('.', ''))).join(',')}
className="hidden" className="hidden"
id="proposalDoc" id="proposalDoc"
onChange={handleProposalDocChange} onChange={handleProposalDocChange}
@ -381,6 +615,7 @@ export function DealerProposalSubmissionModal({
ref={otherDocsInputRef} ref={otherDocsInputRef}
type="file" type="file"
multiple multiple
accept={documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',')}
className="hidden" className="hidden"
id="otherDocs" id="otherDocs"
onChange={handleOtherDocsChange} onChange={handleOtherDocsChange}
@ -404,8 +639,11 @@ export function DealerProposalSubmissionModal({
) : ( ) : (
<> <>
<Upload className="w-8 h-8 text-gray-400" /> <Upload className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600 text-center">
Click to upload additional documents (multiple files allowed) Click to upload additional documents
</span>
<span className="text-[10px] text-gray-400">
Max {documentPolicy.maxFileSizeMB}MB | {documentPolicy.allowedFileTypes.join(', ').toUpperCase()}
</span> </span>
</> </>
)} )}
@ -658,94 +896,18 @@ export function DealerProposalSubmissionModal({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
{/* File Preview Modal - Matching DocumentsTab style */} {/* Standardized File Preview */}
{previewFile && ( {previewDoc && (
<Dialog <FilePreview
open={!!previewFile} fileName={previewDoc.fileName}
onOpenChange={() => { fileType={previewDoc.fileType}
if (previewFile?.url) { fileUrl={previewDoc.fileUrl}
URL.revokeObjectURL(previewFile.url); fileSize={previewDoc.fileSize}
} attachmentId={previewDoc.documentId}
setPreviewFile(null); onDownload={downloadDocument}
}} open={!!previewDoc}
> onClose={() => setPreviewDoc(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>
)} )}
</Dialog> </Dialog>
); );

View File

@ -25,10 +25,13 @@ import {
MessageSquare, MessageSquare,
Download, Download,
Eye, Eye,
Loader2, Plus,
Minus,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi'; 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 '@/components/common/FilePreview/FilePreview.css';
import './DealerProposalModal.css'; import './DealerProposalModal.css';
@ -60,11 +63,13 @@ interface InitiatorProposalApprovalModalProps {
onClose: () => void; onClose: () => void;
onApprove: (comments: string) => Promise<void>; onApprove: (comments: string) => Promise<void>;
onReject: (comments: string) => Promise<void>; onReject: (comments: string) => Promise<void>;
onRequestRevision?: (comments: string) => Promise<void>;
proposalData: ProposalData | null; proposalData: ProposalData | null;
dealerName?: string; dealerName?: string;
activityName?: string; activityName?: string;
requestId?: string; requestId?: string;
request?: any; // Request object to check IO blocking status request?: any; // Request object to check IO blocking status
previousProposalData?: any;
} }
export function InitiatorProposalApprovalModal({ export function InitiatorProposalApprovalModal({
@ -72,27 +77,30 @@ export function InitiatorProposalApprovalModal({
onClose, onClose,
onApprove, onApprove,
onReject, onReject,
onRequestRevision,
proposalData, proposalData,
dealerName = 'Dealer', dealerName = 'Dealer',
activityName = 'Activity', activityName = 'Activity',
requestId: _requestId, // Prefix with _ to indicate intentionally unused requestId: _requestId, // Prefix with _ to indicate intentionally unused
request, request,
previousProposalData,
}: InitiatorProposalApprovalModalProps) { }: InitiatorProposalApprovalModalProps) {
const [comments, setComments] = useState(''); const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | null>(null); 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) // Check if IO is blocked (IO blocking moved to Requestor Evaluation level)
const internalOrder = request?.internalOrder || request?.internal_order; const internalOrder = request?.internalOrder || request?.internal_order;
const ioBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0; const ioBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
const isIOBlocked = ioBlockedAmount > 0; const isIOBlocked = ioBlockedAmount > 0;
const [previewDocument, setPreviewDocument] = useState<{ const [previewDoc, setPreviewDoc] = useState<{
name: string; name: string;
url: string; url: string;
type?: string; type?: string;
size?: number; size?: number;
id?: string;
} | null>(null); } | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
// Calculate total budget // Calculate total budget
const totalBudget = useMemo(() => { const totalBudget = useMemo(() => {
@ -140,75 +148,40 @@ export function InitiatorProposalApprovalModal({
name.endsWith('.webp'); name.endsWith('.webp');
}; };
// Handle document preview - fetch as blob to avoid CSP issues // Handle document preview - leverage FilePreview's internal fetching
const handlePreviewDocument = async (doc: { name: string; url?: string; id?: string }) => { const handlePreviewDocument = (doc: { name: string; url?: string; id?: string; storageUrl?: string; documentId?: string }) => {
if (!doc.id) { let fileUrl = doc.url || doc.storageUrl || '';
toast.error('Document preview not available - document ID missing'); const documentId = doc.id || doc.documentId || '';
if (!documentId && !fileUrl) {
toast.error('Document preview not available');
return; return;
} }
setPreviewLoading(true); // Handle relative URLs for snapshots
try { if (fileUrl && !fileUrl.startsWith('http') && !fileUrl.startsWith('blob:')) {
const previewUrl = getDocumentPreviewUrl(doc.id); const baseUrl = import.meta.env.VITE_BASE_URL || '';
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
// Determine file type from name const cleanFileUrl = fileUrl.startsWith('/') ? fileUrl : `/${fileUrl}`;
const fileName = doc.name.toLowerCase(); fileUrl = `${cleanBaseUrl}${cleanFileUrl}`;
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, { setPreviewDoc({
headers, name: doc.name || 'Document',
credentials: 'include', url: fileUrl || (documentId ? getDocumentPreviewUrl(documentId) : ''),
mode: 'cors' type: (doc.name || '').toLowerCase().endsWith('.pdf') ? 'application/pdf' : 'image/jpeg',
id: documentId
}); });
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 // Cleanup blob URLs on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
if (previewDocument?.url && previewDocument.url.startsWith('blob:')) { if (previewDoc?.url && previewDoc.url.startsWith('blob:')) {
window.URL.revokeObjectURL(previewDocument.url); window.URL.revokeObjectURL(previewDoc.url);
} }
}; };
}, [previewDocument]); }, [previewDoc]);
const handleApprove = async () => { const handleApprove = async () => {
if (!comments.trim()) { if (!comments.trim()) {
@ -252,6 +225,32 @@ export function InitiatorProposalApprovalModal({
} }
}; };
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 = () => { const handleReset = () => {
setComments(''); setComments('');
setActionType(null); setActionType(null);
@ -296,7 +295,163 @@ export function InitiatorProposalApprovalModal({
</div> </div>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3 lg:py-4 px-6"> <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"> <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 */} {/* Left Column - Documents */}
<div className="space-y-4 lg:space-y-4 flex flex-col"> <div className="space-y-4 lg:space-y-4 flex flex-col">
@ -330,15 +485,10 @@ export function InitiatorProposalApprovalModal({
<button <button
type="button" type="button"
onClick={() => handlePreviewDocument(proposalData.proposalDocument!)} onClick={() => handlePreviewDocument(proposalData.proposalDocument!)}
disabled={previewLoading} className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Preview document" title="Preview document"
> >
{previewLoading ? (
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
) : (
<Eye className="w-5 h-5 text-blue-600" /> <Eye className="w-5 h-5 text-blue-600" />
)}
</button> </button>
)} )}
<button <button
@ -397,15 +547,10 @@ export function InitiatorProposalApprovalModal({
<button <button
type="button" type="button"
onClick={() => handlePreviewDocument(doc)} onClick={() => handlePreviewDocument(doc)}
disabled={previewLoading} className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Preview document" title="Preview document"
> >
{previewLoading ? (
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
) : (
<Eye className="w-5 h-5 text-blue-600" /> <Eye className="w-5 h-5 text-blue-600" />
)}
</button> </button>
)} )}
<button <button
@ -563,6 +708,21 @@ export function InitiatorProposalApprovalModal({
</Button> </Button>
<div className="flex flex-col gap-2 w-full sm:w-auto"> <div className="flex flex-col gap-2 w-full sm:w-auto">
<div className="flex flex-col sm:flex-row gap-2"> <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 <Button
onClick={handleReject} onClick={handleReject}
disabled={!comments.trim() || submitting} disabled={!comments.trim() || submitting}
@ -604,108 +764,18 @@ export function InitiatorProposalApprovalModal({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
{/* File Preview Modal - Matching DocumentsTab style */} {/* Standardized File Preview */}
{previewDocument && ( {previewDoc && (
<Dialog <FilePreview
open={!!previewDocument} fileName={previewDoc.name}
onOpenChange={() => setPreviewDocument(null)} fileType={previewDoc.type || ''}
> fileUrl={previewDoc.url}
<DialogContent className="file-preview-dialog p-3 sm:p-6"> fileSize={previewDoc.size}
<div className="file-preview-content"> attachmentId={previewDoc.id}
<DialogHeader className="pb-4 flex-shrink-0 pr-8"> onDownload={downloadDocument}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> open={!!previewDoc}
<div className="flex items-center gap-3 flex-1 min-w-0"> onClose={() => setPreviewDoc(null)}
<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> </Dialog>
); );

View File

@ -0,0 +1,307 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
FileText,
Calendar,
Receipt,
AlignLeft
} from "lucide-react";
import { DocumentCard } from '@/components/workflow/DocumentUpload/DocumentCard';
import { FilePreview } from '@/components/common/FilePreview/FilePreview';
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
interface SnapshotDetailsModalProps {
isOpen: boolean;
onClose: () => void;
snapshot: any;
type: 'PROPOSAL' | 'COMPLETION';
title?: string;
}
export function SnapshotDetailsModal({
isOpen,
onClose,
snapshot,
type,
title
}: SnapshotDetailsModalProps) {
// State for preview
const [previewDoc, setPreviewDoc] = useState<{
fileName: string;
fileType: string;
documentId: string;
fileUrl?: string;
fileSize?: number;
} | null>(null);
if (!snapshot) return null;
const isProposal = type === 'PROPOSAL';
// Helper to format currency
const formatCurrency = (amount: number | string) => {
return Number(amount || 0).toLocaleString('en-IN', {
maximumFractionDigits: 2,
style: 'currency',
currency: 'INR'
});
};
// Helper to format date
const formatDate = (dateString: string) => {
if (!dateString) return null;
try {
return new Date(dateString).toLocaleDateString('en-IN', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
} catch {
return dateString;
}
};
// Helper to check if file is previewable
const canPreview = (fileName: string): boolean => {
if (!fileName) return false;
const name = fileName.toLowerCase();
return name.endsWith('.pdf') ||
!!name.match(/\.(jpg|jpeg|png|gif|webp)$/i);
};
// Helper to get file type for DocumentCard
const getFileType = (fileName: string) => {
const ext = (fileName || '').split('.').pop()?.toLowerCase();
if (ext === 'pdf') return 'pdf';
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext || '')) return 'image';
return 'file';
};
// Handle document preview click
const handlePreview = (doc: any) => {
const fileName = doc.fileName || doc.originalFileName || (isProposal ? 'Proposal Document' : 'Completion Document');
const documentId = doc.documentId || '';
const fileType = fileName.toLowerCase().endsWith('.pdf') ? 'application/pdf' : 'image/jpeg';
let fileUrl = '';
if (documentId) {
fileUrl = getDocumentPreviewUrl(documentId);
} else {
// Fallback for documents without ID (using direct storageUrl)
fileUrl = doc.storageUrl || doc.documentUrl || '';
if (fileUrl && !fileUrl.startsWith('http')) {
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({
fileName,
fileType,
documentId,
fileUrl,
fileSize: doc.sizeBytes
});
};
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
<DialogHeader className="px-6 py-4 border-b">
<DialogTitle className="flex items-center gap-2">
{isProposal ? (
<FileText className="w-5 h-5 text-blue-600" />
) : (
<Receipt className="w-5 h-5 text-green-600" />
)}
{title || (isProposal ? 'Proposal Snapshot Details' : 'Completion Snapshot Details')}
</DialogTitle>
<DialogDescription>
View detailed snapshot of the {isProposal ? 'proposal' : 'completion request'} at this version.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto min-h-0 px-6 py-4">
<div className="space-y-6">
{/* Header Stats */}
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-gray-50 rounded-lg border border-gray-100">
<p className="text-xs text-gray-500 font-medium mb-1">
{isProposal ? 'Total Budget' : 'Total Expenses'}
</p>
<p className={`text-lg font-bold ${isProposal ? 'text-blue-700' : 'text-green-700'}`}>
{formatCurrency(snapshot.totalBudget || snapshot.totalExpenses)}
</p>
</div>
{isProposal && snapshot.expectedCompletionDate && (
<div className="p-3 bg-gray-50 rounded-lg border border-gray-100">
<p className="text-xs text-gray-500 font-medium mb-1 flex items-center gap-1">
<Calendar className="w-3 h-3" />
Expected Completion
</p>
<p className="text-sm font-semibold text-gray-700">
{formatDate(snapshot.expectedCompletionDate)}
</p>
</div>
)}
</div>
{/* Main Document */}
{snapshot.documentUrl && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-900 border-b pb-1">
Primary Document
</h4>
<DocumentCard
document={{
documentId: '',
name: isProposal ? 'Proposal Document' : 'Completion Document',
fileType: getFileType(snapshot.documentUrl),
uploadedAt: new Date().toISOString()
}}
onPreview={canPreview(snapshot.documentUrl) ? () => handlePreview({
fileName: isProposal ? 'Proposal Document' : 'Completion Document',
documentUrl: snapshot.documentUrl
}) : undefined}
onDownload={async () => {
// Handle download for document without ID
let downloadUrl = snapshot.documentUrl;
if (!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}`;
}
window.open(downloadUrl, '_blank');
}}
/>
</div>
)}
{/* Supporting Documents */}
{snapshot.otherDocuments && snapshot.otherDocuments.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-900 border-b pb-1 flex items-center justify-between">
<span>Supporting Documents</span>
<Badge variant="secondary" className="text-[10px] h-5">
{snapshot.otherDocuments.length} Files
</Badge>
</h4>
<div className="space-y-2">
{snapshot.otherDocuments.map((doc: any, idx: number) => (
<DocumentCard
key={idx}
document={{
documentId: doc.documentId || '',
name: doc.originalFileName || doc.fileName || 'Supporting Document',
fileType: getFileType(doc.originalFileName || doc.fileName || ''),
uploadedAt: doc.uploadedAt || new Date().toISOString()
}}
onPreview={canPreview(doc.originalFileName || doc.fileName || '') ? () => handlePreview(doc) : undefined}
onDownload={doc.documentId ? downloadDocument : async () => {
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>
)}
{/* Cost Breakup / Expenses */}
{(snapshot.costItems || snapshot.expenses) && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-900 border-b pb-1">
{isProposal ? 'Cost Breakdown' : 'Expenses Breakdown'}
</h4>
<div className="border rounded-md overflow-hidden text-sm">
<table className="w-full text-left">
<thead className="bg-gray-50 text-gray-600 text-xs uppercase">
<tr>
<th className="p-3 font-medium">Description</th>
<th className="p-3 font-medium text-right">Amount</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{(snapshot.costItems || snapshot.expenses).length > 0 ? (
(snapshot.costItems || snapshot.expenses).map((item: any, idx: number) => (
<tr key={idx} className="bg-white hover:bg-gray-50/50">
<td className="p-3 text-gray-800">{item.description}</td>
<td className="p-3 text-right text-gray-900 font-medium tabular-nums">
{formatCurrency(item.amount)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={2} className="p-4 text-center text-gray-500 italic text-xs">
No breakdown items available
</td>
</tr>
)}
<tr className="bg-gray-50/80 font-semibold text-gray-900 border-t-2 border-gray-100">
<td className="p-3">Total</td>
<td className="p-3 text-right tabular-nums">
{formatCurrency(snapshot.totalBudget || snapshot.totalExpenses)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
{/* Comments */}
{snapshot.comments && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-900 border-b pb-1 flex items-center gap-1">
<AlignLeft className="w-4 h-4" />
Comments
</h4>
<div className="bg-gray-50 rounded-lg p-3 text-sm text-gray-700 italic border border-gray-100">
{snapshot.comments}
</div>
</div>
)}
</div>
</div>
<div className="px-6 py-4 border-t bg-gray-50 flex justify-end">
<Button onClick={onClose}>Close</Button>
</div>
</DialogContent>
</Dialog>
{/* File Preview */}
{previewDoc && (
<FilePreview
fileName={previewDoc.fileName}
fileType={previewDoc.fileType}
fileUrl={previewDoc.fileUrl}
fileSize={previewDoc.fileSize}
attachmentId={previewDoc.documentId}
onDownload={downloadDocument}
open={!!previewDoc}
onClose={() => setPreviewDoc(null)}
/>
)}
</>
);
}

View File

@ -13,5 +13,6 @@ export { DeptLeadIOApprovalModal } from './DeptLeadIOApprovalModal';
export { DMSPushModal } from './DMSPushModal'; export { DMSPushModal } from './DMSPushModal';
export { EditClaimAmountModal } from './EditClaimAmountModal'; export { EditClaimAmountModal } from './EditClaimAmountModal';
export { EmailNotificationTemplateModal } from './EmailNotificationTemplateModal'; export { EmailNotificationTemplateModal } from './EmailNotificationTemplateModal';
export { InitiatorProposalApprovalModal } from './InitiatorProposalApprovalModal';
export { InitiatorActionModal } from './InitiatorActionModal'; export { InitiatorActionModal } from './InitiatorActionModal';
export { InitiatorProposalApprovalModal } from './InitiatorProposalApprovalModal';
export { SnapshotDetailsModal } from './SnapshotDetailsModal';

View File

@ -621,6 +621,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
setShowSkipApproverModal(true); setShowSkipApproverModal(true);
}} }}
onRefresh={refreshDetails} onRefresh={refreshDetails}
documentPolicy={documentPolicy}
/> />
</TabsContent> </TabsContent>