clim approval versioning enhanced view detail for proposal and completion snapshot
This commit is contained in:
parent
a3a142d603
commit
66c33703e1
@ -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>
|
||||||
|
|||||||
@ -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,232 +1568,142 @@ 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">
|
<p className="text-[10px] text-gray-600">
|
||||||
View Document
|
Budget: ₹{Number(version.snapshotData.totalBudget || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
</a>
|
</p>
|
||||||
</p>
|
</div>
|
||||||
)}
|
<button
|
||||||
<p className="text-[10px] text-gray-600">
|
className="text-[10px] text-blue-600 hover:text-blue-800 hover:underline font-medium flex items-center gap-1 transition-colors"
|
||||||
Budget: ₹{Number(step.versionHistory.previous.snapshotData.totalBudget || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
onClick={() => setViewSnapshot({
|
||||||
</p>
|
data: version.snapshotData,
|
||||||
{step.versionHistory.previous.snapshotData.comments && (
|
type: 'PROPOSAL',
|
||||||
<p className="text-[10px] text-gray-600 mt-1">
|
title: `Proposal (v${version.version})`
|
||||||
Comments: {step.versionHistory.previous.snapshotData.comments.substring(0, 100)}
|
})}
|
||||||
{step.versionHistory.previous.snapshotData.comments.length > 100 ? '...' : ''}
|
>
|
||||||
|
<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 && (
|
||||||
)}
|
<p className="text-[10px] text-gray-600">
|
||||||
{step.versionHistory.previous.snapshotType === 'COMPLETION' && step.versionHistory.previous.snapshotData && (
|
SAP Doc: {version.snapshotData.sapDocumentNumber}
|
||||||
<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">
|
|
||||||
Total Expenses: ₹{Number(step.versionHistory.previous.snapshotData.totalExpenses || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() || '';
|
||||||
return;
|
|
||||||
|
// 1. Check file size
|
||||||
|
if (file.size > maxSizeBytes) {
|
||||||
|
toast.error(`"${file.name}" exceeds ${documentPolicy.maxFileSizeMB}MB limit and was not added.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check field-specific types
|
||||||
|
if (!allowedExts.includes(fileExt)) {
|
||||||
|
toast.error(`"${file.name}" is not a supported document type (PDF, DOC, ZIP).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check system policy types
|
||||||
|
if (!documentPolicy.allowedFileTypes.includes(simpleExt)) {
|
||||||
|
toast.error(`"${file.name}" has an unallowed file type according to system policy.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
validFiles.push(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
setCompletionDocuments([...completionDocuments, ...validFiles]);
|
||||||
}
|
}
|
||||||
setCompletionDocuments([...completionDocuments, ...files]);
|
|
||||||
|
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.)');
|
|
||||||
return;
|
// 1. Check file size
|
||||||
|
if (file.size > maxSizeBytes) {
|
||||||
|
toast.error(`Photo "${file.name}" exceeds ${documentPolicy.maxFileSizeMB}MB limit.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check field-specific (Image)
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast.error(`"${file.name}" is not an image file.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check system policy
|
||||||
|
if (!documentPolicy.allowedFileTypes.includes(simpleExt)) {
|
||||||
|
toast.error(`"${file.name}" has an unsupported image format.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
validFiles.push(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
setActivityPhotos([...activityPhotos, ...validFiles]);
|
||||||
}
|
}
|
||||||
setActivityPhotos([...activityPhotos, ...files]);
|
|
||||||
|
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() || '';
|
||||||
return;
|
|
||||||
|
// 1. Check file size
|
||||||
|
if (file.size > maxSizeBytes) {
|
||||||
|
toast.error(`Invoice "${file.name}" exceeds ${documentPolicy.maxFileSizeMB}MB limit.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check field-specific
|
||||||
|
if (!allowedExts.includes(fileExt)) {
|
||||||
|
toast.error(`"${file.name}" is not a supported type (PDF, JPG, PNG).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check system policy
|
||||||
|
if (!documentPolicy.allowedFileTypes.includes(simpleExt)) {
|
||||||
|
toast.error(`"${file.name}" format is not allowed by system policy.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
validFiles.push(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
setInvoicesReceipts([...invoicesReceipts, ...validFiles]);
|
||||||
}
|
}
|
||||||
setInvoicesReceipts([...invoicesReceipts, ...files]);
|
|
||||||
|
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}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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, {
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPreviewDoc({
|
||||||
|
name: doc.name || 'Document',
|
||||||
|
url: fileUrl || (documentId ? getDocumentPreviewUrl(documentId) : ''),
|
||||||
|
type: (doc.name || '').toLowerCase().endsWith('.pdf') ? 'application/pdf' : 'image/jpeg',
|
||||||
|
id: documentId
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cleanup blob URLs on unmount
|
// 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 ? (
|
<Eye className="w-5 h-5 text-blue-600" />
|
||||||
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<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 ? (
|
<Eye className="w-5 h-5 text-blue-600" />
|
||||||
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|||||||
@ -621,6 +621,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
setShowSkipApproverModal(true);
|
setShowSkipApproverModal(true);
|
||||||
}}
|
}}
|
||||||
onRefresh={refreshDetails}
|
onRefresh={refreshDetails}
|
||||||
|
documentPolicy={documentPolicy}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user