manager is pickd from the user tam and search user by dispaly name strategy

This commit is contained in:
laxmanhalaki 2025-12-12 21:38:01 +05:30
parent 636dc4a1c5
commit 001d636e6c
9 changed files with 650 additions and 157 deletions

View File

@ -24,16 +24,9 @@ import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase'; import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
import { AuthCallback } from '@/pages/Auth/AuthCallback'; import { AuthCallback } from '@/pages/Auth/AuthCallback';
import { createClaimRequest } from '@/services/dealerClaimApi'; import { createClaimRequest } from '@/services/dealerClaimApi';
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
// Combined Request Database for backward compatibility
// This combines both custom and claim management requests
export const REQUEST_DATABASE: any = {
...CUSTOM_REQUEST_DATABASE,
...CLAIM_MANAGEMENT_DATABASE
};
interface AppProps { interface AppProps {
onLogout?: () => void; onLogout?: () => void;
@ -62,6 +55,20 @@ function AppRoutes({ onLogout }: AppProps) {
const [dynamicRequests, setDynamicRequests] = useState<any[]>([]); const [dynamicRequests, setDynamicRequests] = useState<any[]>([]);
const [selectedRequestId, setSelectedRequestId] = useState<string>(''); const [selectedRequestId, setSelectedRequestId] = useState<string>('');
const [selectedRequestTitle, setSelectedRequestTitle] = useState<string>(''); const [selectedRequestTitle, setSelectedRequestTitle] = useState<string>('');
const [managerModalOpen, setManagerModalOpen] = useState(false);
const [managerModalData, setManagerModalData] = useState<{
errorType: 'NO_MANAGER_FOUND' | 'MULTIPLE_MANAGERS_FOUND';
managers?: Array<{
userId: string;
email: string;
displayName: string;
firstName?: string;
lastName?: string;
department?: string;
}>;
message?: string;
pendingClaimData?: any;
} | null>(null);
// Retrieve dynamic requests from localStorage on mount // Retrieve dynamic requests from localStorage on mount
useEffect(() => { useEffect(() => {
@ -266,7 +273,7 @@ function AppRoutes({ onLogout }: AppProps) {
setApprovalAction(null); setApprovalAction(null);
}; };
const handleClaimManagementSubmit = async (claimData: any) => { const handleClaimManagementSubmit = async (claimData: any, selectedManagerEmail?: string) => {
try { try {
// Prepare payload for API // Prepare payload for API
const payload = { const payload = {
@ -283,12 +290,17 @@ function AppRoutes({ onLogout }: AppProps) {
periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined, periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined,
periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined, periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined,
estimatedBudget: claimData.estimatedBudget || undefined, estimatedBudget: claimData.estimatedBudget || undefined,
selectedManagerEmail: selectedManagerEmail || undefined,
}; };
// Call API to create claim request // Call API to create claim request
const response = await createClaimRequest(payload); const response = await createClaimRequest(payload);
const createdRequest = response.request; const createdRequest = response.request;
// Close manager modal if open
setManagerModalOpen(false);
setManagerModalData(null);
toast.success('Claim Request Submitted', { toast.success('Claim Request Submitted', {
description: 'Your claim management request has been created successfully.', description: 'Your claim management request has been created successfully.',
}); });
@ -301,6 +313,36 @@ function AppRoutes({ onLogout }: AppProps) {
} }
} catch (error: any) { } catch (error: any) {
console.error('[App] Error creating claim request:', error); console.error('[App] Error creating claim request:', error);
// Check for manager-related errors
const errorData = error?.response?.data;
const errorCode = errorData?.code || errorData?.error?.code;
if (errorCode === 'NO_MANAGER_FOUND') {
// Show modal for no manager found
setManagerModalData({
errorType: 'NO_MANAGER_FOUND',
message: errorData?.message || errorData?.error?.message || 'No reporting manager found. Please ensure your manager is correctly configured in the system.',
pendingClaimData: claimData,
});
setManagerModalOpen(true);
return;
}
if (errorCode === 'MULTIPLE_MANAGERS_FOUND') {
// Show modal with manager list for selection
const managers = errorData?.managers || errorData?.error?.managers || [];
setManagerModalData({
errorType: 'MULTIPLE_MANAGERS_FOUND',
managers: managers,
message: errorData?.message || errorData?.error?.message || 'Multiple managers found. Please select one.',
pendingClaimData: claimData,
});
setManagerModalOpen(true);
return;
}
// Other errors - show toast
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create claim request'; const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create claim request';
toast.error('Failed to Submit Claim Request', { toast.error('Failed to Submit Claim Request', {
description: errorMessage, description: errorMessage,
@ -719,6 +761,27 @@ function AppRoutes({ onLogout }: AppProps) {
}} }}
/> />
{/* Manager Selection Modal */}
<ManagerSelectionModal
open={managerModalOpen}
onClose={() => {
setManagerModalOpen(false);
setManagerModalData(null);
}}
onSelect={async (managerEmail: string) => {
if (managerModalData?.pendingClaimData) {
// Retry creating claim request with selected manager
// The pendingClaimData contains all the form data from the wizard
// This preserves the entire submission state while waiting for manager selection
await handleClaimManagementSubmit(managerModalData.pendingClaimData, managerEmail);
}
}}
managers={managerModalData?.managers}
errorType={managerModalData?.errorType || 'NO_MANAGER_FOUND'}
message={managerModalData?.message}
isLoading={false} // Will be set to true during retry if needed
/>
{/* Approval Action Modal */} {/* Approval Action Modal */}
{approvalAction && ( {approvalAction && (
<ApprovalActionModal <ApprovalActionModal

View File

@ -0,0 +1,164 @@
/**
* Manager Selection Modal
* Shows when multiple managers are found or no manager is found
* Allows user to select a manager from the list
*/
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertCircle, CheckCircle2, User, Mail, Building2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
interface Manager {
userId: string;
email: string;
displayName: string;
firstName?: string;
lastName?: string;
department?: string;
}
interface ManagerSelectionModalProps {
open: boolean;
onClose: () => void;
onSelect: (managerEmail: string) => void;
managers?: Manager[];
errorType: 'NO_MANAGER_FOUND' | 'MULTIPLE_MANAGERS_FOUND';
message?: string;
isLoading?: boolean;
}
export function ManagerSelectionModal({
open,
onClose,
onSelect,
managers = [],
errorType,
message,
isLoading = false,
}: ManagerSelectionModalProps) {
const handleSelect = (managerEmail: string) => {
onSelect(managerEmail);
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{errorType === 'NO_MANAGER_FOUND' ? (
<>
<AlertCircle className="w-5 h-5 text-amber-500" />
Manager Not Found
</>
) : (
<>
<CheckCircle2 className="w-5 h-5 text-blue-500" />
Select Your Manager
</>
)}
</DialogTitle>
<DialogDescription>
{errorType === 'NO_MANAGER_FOUND' ? (
<div className="mt-2">
<p className="text-sm text-gray-600">
{message || 'No reporting manager found in the system. Please ensure your manager is correctly configured in your profile.'}
</p>
<p className="text-sm text-gray-500 mt-2">
Please contact your administrator to update your manager information, or try again later.
</p>
</div>
) : (
<div className="mt-2">
<p className="text-sm text-gray-600">
{message || 'Multiple managers were found with the same name. Please select the correct manager from the list below.'}
</p>
</div>
)}
</DialogDescription>
</DialogHeader>
<div className="mt-4">
{errorType === 'NO_MANAGER_FOUND' ? (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-amber-900">
Unable to Proceed
</p>
<p className="text-sm text-amber-700 mt-1">
We couldn't find your reporting manager in the system. The claim request cannot be created without a valid manager assigned.
</p>
</div>
</div>
</div>
) : (
<div className="space-y-3 max-h-[400px] overflow-y-auto">
{managers.map((manager) => (
<div
key={manager.userId}
className="border rounded-lg p-4 hover:bg-gray-50 transition-colors cursor-pointer"
onClick={() => !isLoading && handleSelect(manager.email)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="font-medium text-gray-900">
{manager.displayName || `${manager.firstName || ''} ${manager.lastName || ''}`.trim() || 'Unknown'}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail className="w-4 h-4" />
<span className="truncate">{manager.email}</span>
</div>
{manager.department && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Building2 className="w-4 h-4" />
<span>{manager.department}</span>
</div>
)}
</div>
</div>
</div>
<Button
onClick={(e) => {
e.stopPropagation();
handleSelect(manager.email);
}}
disabled={isLoading}
className="flex-shrink-0"
size="sm"
>
Select
</Button>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex justify-end gap-3 mt-6">
{errorType === 'NO_MANAGER_FOUND' ? (
<Button onClick={onClose} variant="outline">
Close
</Button>
) : (
<>
<Button onClick={onClose} variant="outline" disabled={isLoading}>
Cancel
</Button>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -151,23 +151,55 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
(level.levelNumber || level.level_number) === 3 (level.levelNumber || level.level_number) === 3
); );
const deptLeadUserId = step3Level?.approverId || step3Level?.approver?.userId; const deptLeadUserId = step3Level?.approverId || step3Level?.approver?.userId;
const isDeptLead = deptLeadUserId && deptLeadUserId === currentUserId; const deptLeadEmail = (step3Level?.approverEmail || step3Level?.approver?.email || step3Level?.approverEmail || '').toLowerCase().trim();
// Check if user is department lead by userId or email (case-insensitive)
const isDeptLead = (deptLeadUserId && deptLeadUserId === currentUserId) ||
(deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail);
// Get step 3 status (case-insensitive check)
const step3Status = step3Level?.status ? String(step3Level.status).toUpperCase() : '';
const isStep3PendingOrInProgress = step3Status === 'PENDING' ||
step3Status === 'IN_PROGRESS';
// Check if user is current approver for step 3 (can access IO tab when step is pending/in-progress)
// Also check if currentLevel is 3 (workflow is at step 3)
const currentLevel = apiRequest?.currentLevel || apiRequest?.current_level || 0;
const isStep3CurrentLevel = currentLevel === 3;
const isStep3CurrentApprover = step3Level && isStep3PendingOrInProgress && isStep3CurrentLevel && (
(deptLeadUserId && deptLeadUserId === currentUserId) ||
(deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail)
);
// Check if IO tab should be visible (for initiator and department lead in claim management requests) // Check if IO tab should be visible (for initiator and department lead in claim management requests)
// Department lead can access IO tab when they are the current approver for step 3 (to fetch and block IO)
const showIOTab = isClaimManagementRequest(apiRequest) && const showIOTab = isClaimManagementRequest(apiRequest) &&
(isUserInitiator || isDeptLead); (isUserInitiator || isDeptLead || isStep3CurrentApprover);
// Debug logging for troubleshooting // Debug logging for troubleshooting
console.debug('[RequestDetail] IO Tab visibility:', { console.debug('[RequestDetail] IO Tab visibility:', {
isClaimManagement: isClaimManagementRequest(apiRequest), isClaimManagement: isClaimManagementRequest(apiRequest),
isUserInitiator, isUserInitiator,
isDeptLead, isDeptLead,
isStep3CurrentApprover,
currentUserId, currentUserId,
currentUserEmail, currentUserEmail,
initiatorUserId, initiatorUserId,
initiatorEmail, initiatorEmail,
step3Level: step3Level ? { levelNumber: step3Level.levelNumber || step3Level.level_number, approverId: step3Level.approverId || step3Level.approver?.userId } : null, currentLevel,
isStep3CurrentLevel,
step3Level: step3Level ? {
levelNumber: step3Level.levelNumber || step3Level.level_number,
approverId: step3Level.approverId || step3Level.approver?.userId,
approverEmail: step3Level.approverEmail || step3Level.approver?.email,
status: step3Level.status,
statusUpper: step3Status,
isPendingOrInProgress: isStep3PendingOrInProgress
} : null,
deptLeadUserId, deptLeadUserId,
deptLeadEmail,
emailMatch: deptLeadEmail && currentUserEmail ? deptLeadEmail === currentUserEmail : false,
showIOTab, showIOTab,
}); });

View File

@ -27,6 +27,26 @@ interface ProposalDetailsCardProps {
} }
export function ProposalDetailsCard({ proposalDetails, className }: ProposalDetailsCardProps) { export function ProposalDetailsCard({ proposalDetails, className }: ProposalDetailsCardProps) {
// Calculate estimated total from costBreakup if not provided
const calculateEstimatedTotal = () => {
if (proposalDetails.estimatedBudgetTotal !== undefined && proposalDetails.estimatedBudgetTotal !== null) {
return proposalDetails.estimatedBudgetTotal;
}
// Calculate sum from costBreakup items
if (proposalDetails.costBreakup && proposalDetails.costBreakup.length > 0) {
const total = proposalDetails.costBreakup.reduce((sum, item) => {
const amount = item.amount || 0;
return sum + (Number.isNaN(amount) ? 0 : amount);
}, 0);
return total;
}
return 0;
};
const estimatedTotal = calculateEstimatedTotal();
const formatCurrency = (amount?: number | null) => { const formatCurrency = (amount?: number | null) => {
if (amount === undefined || amount === null || Number.isNaN(amount)) { if (amount === undefined || amount === null || Number.isNaN(amount)) {
return '₹0.00'; return '₹0.00';
@ -99,7 +119,7 @@ export function ProposalDetailsCard({ proposalDetails, className }: ProposalDeta
Estimated Budget (Total) Estimated Budget (Total)
</td> </td>
<td className="px-4 py-3 text-sm text-green-700 text-right"> <td className="px-4 py-3 text-sm text-green-700 text-right">
{formatCurrency(proposalDetails.estimatedBudgetTotal)} {formatCurrency(estimatedTotal)}
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

@ -0,0 +1,148 @@
/**
* EmailNotificationTemplateModal Component
* Modal for displaying email notification templates for automated workflow steps
* Used for Step 4: Activity Creation and other auto-triggered steps
*/
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Mail, User, Building, Calendar, X } from 'lucide-react';
interface EmailNotificationTemplateModalProps {
isOpen: boolean;
onClose: () => void;
stepNumber: number;
stepName: string;
requestNumber?: string;
recipientEmail?: string;
subject?: string;
emailBody?: string;
}
export function EmailNotificationTemplateModal({
isOpen,
onClose,
stepNumber,
stepName,
requestNumber = 'RE-REQ-2024-CM-101',
recipientEmail = 'system@royalenfield.com',
subject,
emailBody,
}: EmailNotificationTemplateModalProps) {
// Default subject if not provided
const defaultSubject = `System Notification: Activity Created - ${requestNumber}`;
const finalSubject = subject || defaultSubject;
// Default email body if not provided
const defaultEmailBody = `System Notification
Activity has been automatically created for claim ${requestNumber}.
All stakeholders have been notified.
This is an automated message.`;
const finalEmailBody = emailBody || defaultEmailBody;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-2xl max-w-2xl">
<DialogHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<Mail className="w-5 h-5 text-blue-600" />
</div>
<div>
<DialogTitle className="text-lg leading-none font-semibold">
Email Notification Template
</DialogTitle>
<DialogDescription className="text-sm">
Step {stepNumber}: {stepName}
</DialogDescription>
</div>
</div>
</div>
</DialogHeader>
<div className="space-y-4">
{/* Email Header Section */}
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg p-4 border border-blue-200">
<div className="space-y-2">
<div className="flex items-start gap-2">
<User className="w-4 h-4 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs text-gray-600">To:</p>
<p className="text-sm font-medium text-gray-900">{recipientEmail}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Mail className="w-4 h-4 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs text-gray-600">Subject:</p>
<p className="text-sm font-semibold text-gray-900">{finalSubject}</p>
</div>
</div>
</div>
</div>
{/* Email Body Section */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="space-y-4">
{/* Company Header */}
<div className="flex items-center gap-2 pb-3 border-b border-gray-200">
<Building className="w-5 h-5 text-purple-600" />
<span className="font-semibold text-gray-900">Royal Enfield</span>
</div>
{/* Email Content */}
<div className="prose prose-sm max-w-none">
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 leading-relaxed bg-transparent p-0 border-0">
{finalEmailBody}
</pre>
</div>
{/* Footer */}
<div className="pt-3 border-t border-gray-200">
<div className="flex items-center gap-2 text-xs text-gray-500">
<Calendar className="w-3 h-3" />
<span>Automated email Royal Enfield Claims Portal</span>
</div>
</div>
</div>
</div>
{/* Badges */}
<div className="flex items-center gap-2">
<Badge className="bg-blue-50 text-blue-700 border-blue-200">
Step {stepNumber}
</Badge>
<Badge className="bg-purple-50 text-purple-700 border-purple-200">
Auto-triggered
</Badge>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
onClick={onClose}
className="h-9"
>
<X className="w-4 h-4 mr-2" />
Close
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -20,6 +20,7 @@ import { InitiatorProposalApprovalModal } from '../modals/InitiatorProposalAppro
import { DeptLeadIOApprovalModal } from '../modals/DeptLeadIOApprovalModal'; import { DeptLeadIOApprovalModal } from '../modals/DeptLeadIOApprovalModal';
import { DealerCompletionDocumentsModal } from '../modals/DealerCompletionDocumentsModal'; import { DealerCompletionDocumentsModal } from '../modals/DealerCompletionDocumentsModal';
import { CreditNoteSAPModal } from '../modals/CreditNoteSAPModal'; import { CreditNoteSAPModal } from '../modals/CreditNoteSAPModal';
import { EmailNotificationTemplateModal } from '../modals/EmailNotificationTemplateModal';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { submitProposal, updateIODetails, submitCompletion, updateEInvoice } from '@/services/dealerClaimApi'; import { submitProposal, updateIODetails, submitCompletion, updateEInvoice } from '@/services/dealerClaimApi';
import { getWorkflowDetails, approveLevel, rejectLevel } from '@/services/workflowApi'; import { getWorkflowDetails, approveLevel, rejectLevel } from '@/services/workflowApi';
@ -161,6 +162,8 @@ export function DealerClaimWorkflowTab({
const [showIOApprovalModal, setShowIOApprovalModal] = useState(false); const [showIOApprovalModal, setShowIOApprovalModal] = useState(false);
const [showCompletionModal, setShowCompletionModal] = useState(false); const [showCompletionModal, setShowCompletionModal] = useState(false);
const [showCreditNoteModal, setShowCreditNoteModal] = useState(false); const [showCreditNoteModal, setShowCreditNoteModal] = useState(false);
const [showEmailTemplateModal, setShowEmailTemplateModal] = useState(false);
const [selectedStepForEmail, setSelectedStepForEmail] = useState<{ stepNumber: number; stepName: 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[]>([]);
@ -895,14 +898,17 @@ export function DealerClaimWorkflowTab({
<Badge className={getStepBadgeVariant(step.status)}> <Badge className={getStepBadgeVariant(step.status)}>
{step.status.toLowerCase()} {step.status.toLowerCase()}
</Badge> </Badge>
{/* Email Template Button (Step 4) */} {/* Email Template Button (Step 4) - Show when approved */}
{step.step === 4 && step.emailTemplateUrl && ( {step.step === 4 && step.status === 'approved' && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 w-6 p-0 hover:bg-blue-100" className="h-6 w-6 p-0 hover:bg-blue-100"
title="View email template" title="View email template"
onClick={() => window.open(step.emailTemplateUrl, '_blank')} onClick={() => {
setSelectedStepForEmail({ stepNumber: step.step, stepName: step.title });
setShowEmailTemplateModal(true);
}}
> >
<Mail className="w-3.5 h-3.5 text-blue-600" /> <Mail className="w-3.5 h-3.5 text-blue-600" />
</Button> </Button>
@ -1235,6 +1241,19 @@ export function DealerClaimWorkflowTab({
requestId={request?.requestId || request?.id} requestId={request?.requestId || request?.id}
dueDate={request?.dueDate} dueDate={request?.dueDate}
/> />
{/* Email Notification Template Modal */}
<EmailNotificationTemplateModal
isOpen={showEmailTemplateModal}
onClose={() => {
setShowEmailTemplateModal(false);
setSelectedStepForEmail(null);
}}
stepNumber={selectedStepForEmail?.stepNumber || 4}
stepName={selectedStepForEmail?.stepName || 'Activity Creation'}
requestNumber={request?.requestNumber || request?.id || request?.request_number}
recipientEmail="system@royalenfield.com"
/>
</> </>
); );
} }

View File

@ -13,9 +13,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { DollarSign, Download, CircleCheckBig, AlertCircle } from 'lucide-react'; import { Badge } from '@/components/ui/badge';
import { DollarSign, Download, CircleCheckBig, Target } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { updateIODetails, getClaimDetails } from '@/services/dealerClaimApi'; import { validateIO, updateIODetails, getClaimDetails } from '@/services/dealerClaimApi';
import { useAuth } from '@/contexts/AuthContext';
interface IOTabProps { interface IOTabProps {
request: any; request: any;
@ -26,13 +28,16 @@ interface IOTabProps {
interface IOBlockedDetails { interface IOBlockedDetails {
ioNumber: string; ioNumber: string;
blockedAmount: number; blockedAmount: number;
availableBalance: number; availableBalance: number; // Available amount before block
remainingBalance: number; // Remaining amount after block
blockedDate: string; blockedDate: string;
blockedBy: string; // User who blocked
sapDocumentNumber: string; sapDocumentNumber: string;
status: 'blocked' | 'released' | 'failed'; status: 'blocked' | 'released' | 'failed';
} }
export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) { export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
const { user } = useAuth();
const requestId = apiRequest?.requestId || request?.requestId; const requestId = apiRequest?.requestId || request?.requestId;
// Load existing IO data from apiRequest or request // Load existing IO data from apiRequest or request
@ -42,21 +47,36 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
const existingAvailableBalance = internalOrder?.ioAvailableBalance || internalOrder?.io_available_balance || 0; const existingAvailableBalance = internalOrder?.ioAvailableBalance || internalOrder?.io_available_balance || 0;
const existingRemainingBalance = internalOrder?.ioRemainingBalance || internalOrder?.io_remaining_balance || 0; const existingRemainingBalance = internalOrder?.ioRemainingBalance || internalOrder?.io_remaining_balance || 0;
const sapDocNumber = internalOrder?.sapDocumentNumber || internalOrder?.sap_document_number || ''; const sapDocNumber = internalOrder?.sapDocumentNumber || internalOrder?.sap_document_number || '';
// Get organizer user object from association (organizer) or fallback to organizedBy UUID
const organizer = internalOrder?.organizer || null;
const [ioNumber, setIoNumber] = useState(existingIONumber); const [ioNumber, setIoNumber] = useState(existingIONumber);
const [fetchingAmount, setFetchingAmount] = useState(false); const [fetchingAmount, setFetchingAmount] = useState(false);
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null); const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
const [amountToBlock, setAmountToBlock] = useState<string>('');
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null); const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
const [blockingBudget, setBlockingBudget] = useState(false); const [blockingBudget, setBlockingBudget] = useState(false);
// Load existing IO block details from apiRequest // Load existing IO block details from apiRequest
useEffect(() => { useEffect(() => {
if (internalOrder && existingIONumber) { if (internalOrder && existingIONumber && existingBlockedAmount > 0) {
const availableBeforeBlock = Number(existingAvailableBalance) + Number(existingBlockedAmount) || Number(existingAvailableBalance);
// Get blocked by user name from organizer association (who blocked the amount)
// When amount is blocked, organizedBy stores the user who blocked it
const blockedByName = organizer?.displayName ||
organizer?.display_name ||
organizer?.name ||
(organizer?.firstName && organizer?.lastName ? `${organizer.firstName} ${organizer.lastName}`.trim() : null) ||
organizer?.email ||
'Unknown User';
setBlockedDetails({ setBlockedDetails({
ioNumber: existingIONumber, ioNumber: existingIONumber,
blockedAmount: Number(existingBlockedAmount) || 0, blockedAmount: Number(existingBlockedAmount) || 0,
availableBalance: Number(existingAvailableBalance) || 0, availableBalance: availableBeforeBlock, // Available amount before block
remainingBalance: Number(existingRemainingBalance) || Number(existingAvailableBalance),
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(), blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
blockedBy: blockedByName,
sapDocumentNumber: sapDocNumber, sapDocumentNumber: sapDocNumber,
status: (internalOrder.status === 'BLOCKED' ? 'blocked' : status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed', internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
@ -64,16 +84,16 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
setIoNumber(existingIONumber); setIoNumber(existingIONumber);
// Set fetched amount if available balance exists // Set fetched amount if available balance exists
if (existingAvailableBalance > 0) { if (availableBeforeBlock > 0) {
setFetchedAmount(Number(existingAvailableBalance)); setFetchedAmount(availableBeforeBlock);
} }
} }
}, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber]); }, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
/** /**
* Fetch available budget from SAP * Fetch available budget from SAP
* Validates IO number and gets available balance * Validates IO number and gets available balance (returns dummy data for now)
* Calls updateIODetails with blockedAmount=0 to validate without blocking * Does not store anything in database - only validates
*/ */
const handleFetchAmount = async () => { const handleFetchAmount = async () => {
if (!ioNumber.trim()) { if (!ioNumber.trim()) {
@ -88,32 +108,18 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
setFetchingAmount(true); setFetchingAmount(true);
try { try {
// Validate IO number by calling updateIODetails with blockedAmount=0 // Call validate IO endpoint - returns dummy data for now, will integrate with SAP later
// This validates the IO with SAP and returns available balance without blocking const ioData = await validateIO(requestId, ioNumber.trim());
// The backend will validate the IO number and return the availableBalance from SAP
await updateIODetails(requestId, {
ioNumber: ioNumber.trim(),
ioAvailableBalance: 0, // Will be fetched from SAP validation by backend
ioBlockedAmount: 0, // No blocking, just validation
ioRemainingBalance: 0, // Will be calculated by backend
});
// Fetch updated claim details to get the validated IO data if (ioData.isValid && ioData.availableBalance > 0) {
const claimData = await getClaimDetails(requestId); setFetchedAmount(ioData.availableBalance);
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order; // Pre-fill amount to block with available balance
setAmountToBlock(String(ioData.availableBalance));
if (updatedInternalOrder) { toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
const availableBalance = Number(updatedInternalOrder.ioAvailableBalance || updatedInternalOrder.io_available_balance || 0);
if (availableBalance > 0) {
setFetchedAmount(availableBalance);
toast.success(`IO validated successfully. Available balance: ₹${availableBalance.toLocaleString('en-IN')}`);
} else {
toast.error('No available balance found for this IO number');
setFetchedAmount(null);
}
} else { } else {
toast.error('Failed to fetch IO details after validation'); toast.error('Invalid IO number or no available balance found');
setFetchedAmount(null); setFetchedAmount(null);
setAmountToBlock('');
} }
} catch (error: any) { } catch (error: any) {
console.error('Failed to fetch IO budget:', error); console.error('Failed to fetch IO budget:', error);
@ -139,27 +145,27 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
return; return;
} }
// Get claim amount from budget tracking or proposal details const blockAmount = parseFloat(amountToBlock);
const claimAmount = apiRequest?.budgetTracking?.proposalEstimatedBudget ||
apiRequest?.proposalDetails?.totalEstimatedBudget ||
apiRequest?.claimDetails?.estimatedBudget ||
request?.claimAmount ||
request?.amount ||
0;
if (claimAmount > fetchedAmount) { if (!amountToBlock || isNaN(blockAmount) || blockAmount <= 0) {
toast.error('Claim amount exceeds available IO budget'); toast.error('Please enter a valid amount to block');
return;
}
if (blockAmount > fetchedAmount) {
toast.error('Amount to block exceeds available IO budget');
return; return;
} }
setBlockingBudget(true); setBlockingBudget(true);
try { try {
// Call updateIODetails with blockedAmount to block budget in SAP // Call updateIODetails with blockedAmount to block budget in SAP and store in database
// This will store in internal_orders and claim_budget_tracking tables
await updateIODetails(requestId, { await updateIODetails(requestId, {
ioNumber: ioNumber.trim(), ioNumber: ioNumber.trim(),
ioAvailableBalance: fetchedAmount, ioAvailableBalance: fetchedAmount,
ioBlockedAmount: claimAmount, ioBlockedAmount: blockAmount,
ioRemainingBalance: fetchedAmount - claimAmount, ioRemainingBalance: fetchedAmount - blockAmount,
}); });
// Fetch updated claim details to get the blocked IO data // Fetch updated claim details to get the blocked IO data
@ -167,16 +173,29 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order; const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
if (updatedInternalOrder) { if (updatedInternalOrder) {
const currentUser = user as any;
// When blocking, always use the current user who is performing the block action
// The organizer association may be from initial IO organization, but we want who blocked the amount
const blockedByName = currentUser?.displayName ||
currentUser?.display_name ||
currentUser?.name ||
(currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) ||
currentUser?.email ||
'Current User';
const blocked: IOBlockedDetails = { const blocked: IOBlockedDetails = {
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber, ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
blockedAmount: Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || claimAmount), blockedAmount: Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount),
availableBalance: Number(updatedInternalOrder.ioAvailableBalance || updatedInternalOrder.io_available_balance || fetchedAmount), availableBalance: fetchedAmount, // Available amount before block
remainingBalance: Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || (fetchedAmount - blockAmount)),
blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(), blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(),
blockedBy: blockedByName,
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '', sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
status: 'blocked', status: 'blocked',
}; };
setBlockedDetails(blocked); setBlockedDetails(blocked);
setAmountToBlock(''); // Clear the input
toast.success('IO budget blocked successfully in SAP'); toast.success('IO budget blocked successfully in SAP');
// Refresh request details // Refresh request details
@ -236,14 +255,6 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
} }
}; };
// Get claim amount for display purposes (from budget tracking or proposal details)
const claimAmount = apiRequest?.budgetTracking?.proposalEstimatedBudget ||
apiRequest?.proposalDetails?.totalEstimatedBudget ||
apiRequest?.claimDetails?.estimatedBudget ||
request?.claimAmount ||
request?.amount ||
0;
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* IO Budget Management Card */} {/* IO Budget Management Card */}
@ -283,49 +294,51 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
{/* Fetched Amount Display */} {/* Fetched Amount Display */}
{fetchedAmount !== null && !blockedDetails && ( {fetchedAmount !== null && !blockedDetails && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-3"> <>
<div className="flex items-start justify-between"> <div className="bg-green-50 border-2 border-green-200 rounded-lg p-4">
<div> <div className="flex items-center justify-between">
<p className="text-sm text-green-600 font-medium">Available Budget</p> <div>
<p className="text-2xl font-bold text-green-900"> <p className="text-xs text-gray-600 uppercase tracking-wide mb-1">Available Amount</p>
{fetchedAmount.toLocaleString('en-IN')} <p className="text-2xl font-bold text-green-700">
</p> {fetchedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<CircleCheckBig className="w-8 h-8 text-green-600" />
</div> </div>
<CircleCheckBig className="w-8 h-8 text-green-600" /> <div className="mt-3 pt-3 border-t border-green-200">
</div> <p className="text-xs text-gray-600"><strong>IO Number:</strong> {ioNumber}</p>
<p className="text-xs text-gray-600 mt-1"><strong>Fetched from:</strong> SAP System</p>
<div className="border-t border-green-200 pt-3 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-green-700">Claim Amount:</span>
<span className="font-semibold text-green-900">
{claimAmount.toLocaleString('en-IN')}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-green-700">Balance After Block:</span>
<span className="font-semibold text-green-900">
{(fetchedAmount - claimAmount).toLocaleString('en-IN')}
</span>
</div> </div>
</div> </div>
{claimAmount > fetchedAmount ? ( {/* Amount to Block Input */}
<div className="flex items-center gap-2 bg-red-100 text-red-700 p-3 rounded-md"> <div className="space-y-3">
<AlertCircle className="w-4 h-4" /> <Label htmlFor="blockAmount">Amount to Block *</Label>
<p className="text-xs font-medium"> <div className="relative">
Insufficient budget! Claim amount exceeds available balance. <span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"></span>
</p> <Input
type="number"
id="blockAmount"
placeholder="Enter amount to block"
min="0"
step="0.01"
value={amountToBlock}
onChange={(e) => setAmountToBlock(e.target.value)}
className="pl-8"
/>
</div> </div>
) : ( </div>
<Button
onClick={handleBlockBudget} {/* Block Button */}
disabled={blockingBudget} <Button
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]" onClick={handleBlockBudget}
> disabled={blockingBudget || !amountToBlock || parseFloat(amountToBlock) <= 0 || parseFloat(amountToBlock) > fetchedAmount}
{blockingBudget ? 'Blocking in SAP...' : 'Block Budget in SAP'} className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
</Button> >
)} <Target className="w-4 h-4 mr-2" />
</div> {blockingBudget ? 'Blocking in SAP...' : 'Block IO in SAP'}
</Button>
</>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@ -345,64 +358,69 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
{blockedDetails ? ( {blockedDetails ? (
<div className="space-y-4"> <div className="space-y-4">
{/* Success Banner */} {/* Success Banner */}
<div className="bg-gradient-to-r from-emerald-50 to-green-50 rounded-lg border-2 border-emerald-300 p-4"> <div className="bg-green-50 border-2 border-green-500 rounded-lg p-4">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-emerald-500 flex items-center justify-center"> <CircleCheckBig className="w-6 h-6 text-green-600 flex-shrink-0 mt-0.5" />
<CircleCheckBig className="w-6 h-6 text-white" />
</div>
<div> <div>
<p className="font-semibold text-emerald-900">Budget Blocked Successfully!</p> <p className="font-semibold text-green-900">IO Blocked Successfully</p>
<p className="text-xs text-emerald-700">SAP integration completed</p> <p className="text-sm text-green-700 mt-1">Budget has been reserved in SAP system</p>
</div> </div>
</div> </div>
</div> </div>
{/* Blocked Details */} {/* Blocked Details */}
<div className="space-y-3"> <div className="border rounded-lg divide-y">
<div className="flex justify-between py-2 border-b"> <div className="p-4">
<span className="text-sm text-gray-600">IO Number:</span> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Number</p>
<span className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</span> <p className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</p>
</div> </div>
<div className="flex justify-between py-2 border-b"> <div className="p-4">
<span className="text-sm text-gray-600">Blocked Amount:</span> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">SAP Document Number</p>
<span className="text-sm font-semibold text-green-700"> <p className="text-sm font-semibold text-gray-900">{blockedDetails.sapDocumentNumber || 'N/A'}</p>
{blockedDetails.blockedAmount.toLocaleString('en-IN')}
</span>
</div> </div>
<div className="flex justify-between py-2 border-b"> <div className="p-4 bg-green-50">
<span className="text-sm text-gray-600">Available Balance:</span> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p>
<span className="text-sm font-semibold text-gray-900"> <p className="text-xl font-bold text-green-700">
{blockedDetails.availableBalance.toLocaleString('en-IN')} {blockedDetails.blockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span> </p>
</div> </div>
<div className="flex justify-between py-2 border-b"> <div className="p-4">
<span className="text-sm text-gray-600">Blocked Date:</span> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Available Amount (Before Block)</p>
<span className="text-sm font-medium text-gray-900"> <p className="text-sm font-medium text-gray-900">
{new Date(blockedDetails.blockedDate).toLocaleString('en-IN')} {blockedDetails.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span> </p>
</div> </div>
<div className="flex justify-between py-2 border-b"> <div className="p-4 bg-blue-50">
<span className="text-sm text-gray-600">SAP Document No:</span> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Remaining Amount (After Block)</p>
<span className="text-sm font-mono font-medium text-blue-700"> <p className="text-sm font-bold text-blue-700">
{blockedDetails.sapDocumentNumber} {blockedDetails.remainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span> </p>
</div> </div>
<div className="flex justify-between py-2"> <div className="p-4">
<span className="text-sm text-gray-600">Status:</span> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked By</p>
<span className="text-xs font-semibold px-2 py-1 bg-green-100 text-green-800 rounded-full"> <p className="text-sm font-medium text-gray-900">{blockedDetails.blockedBy}</p>
{blockedDetails.status.toUpperCase()} </div>
</span> <div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked At</p>
<p className="text-sm font-medium text-gray-900">
{new Date(blockedDetails.blockedDate).toLocaleString('en-IN', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})}
</p>
</div>
<div className="p-4 bg-gray-50">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Status</p>
<Badge className="bg-green-100 text-green-800 border-green-200">
<CircleCheckBig className="w-3 h-3 mr-1" />
Blocked
</Badge>
</div> </div>
</div> </div>
{/* Release Button */}
<Button
variant="outline"
onClick={handleReleaseBudget}
className="w-full border-red-300 text-red-700 hover:bg-red-50"
>
Release Budget
</Button>
</div> </div>
) : ( ) : (
<div className="text-center py-12"> <div className="text-center py-12">

View File

@ -19,6 +19,7 @@ export interface CreateClaimRequestPayload {
periodStartDate?: string; // ISO date string periodStartDate?: string; // ISO date string
periodEndDate?: string; // ISO date string periodEndDate?: string; // ISO date string
estimatedBudget?: string | number; estimatedBudget?: string | number;
selectedManagerEmail?: string; // Optional: When multiple managers found, user selects one
} }
export interface ClaimRequestResponse { export interface ClaimRequestResponse {
@ -180,8 +181,34 @@ export async function submitCompletion(
} }
/** /**
* Update IO details (Step 3) * Validate/Fetch IO details from SAP (returns dummy data for now)
* GET /api/v1/dealer-claims/:requestId/io/validate?ioNumber=IO1234
* This only validates and returns IO details, does not store anything
*/
export async function validateIO(
requestId: string,
ioNumber: string
): Promise<{
ioNumber: string;
availableBalance: number;
currency: string;
isValid: boolean;
}> {
try {
const response = await apiClient.get(`/dealer-claims/${requestId}/io/validate`, {
params: { ioNumber }
});
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error validating IO:', error);
throw error;
}
}
/**
* Update IO details and block amount (Step 3)
* PUT /api/v1/dealer-claims/:requestId/io * PUT /api/v1/dealer-claims/:requestId/io
* Only stores data when blocking amount > 0
*/ */
export async function updateIODetails( export async function updateIODetails(
requestId: string, requestId: string,

View File

@ -215,15 +215,17 @@ export function mapToClaimManagementRequest(
} }
// Map proposal details // Map proposal details
const expectedCompletionDate = proposalDetails?.expectedCompletionDate || proposalDetails?.expected_completion_date;
const proposal = proposalDetails ? { const proposal = proposalDetails ? {
proposalDocumentUrl: proposalDetails.proposalDocumentUrl || proposalDetails.proposal_document_url, proposalDocumentUrl: proposalDetails.proposalDocumentUrl || proposalDetails.proposal_document_url,
costBreakup: proposalDetails.costBreakup || proposalDetails.cost_breakup || [], costBreakup: proposalDetails.costBreakup || proposalDetails.cost_breakup || [],
totalEstimatedBudget: proposalDetails.totalEstimatedBudget || proposalDetails.total_estimated_budget || 0, totalEstimatedBudget: proposalDetails.totalEstimatedBudget || proposalDetails.total_estimated_budget || 0,
timelineMode: proposalDetails.timelineMode || proposalDetails.timeline_mode, timelineMode: proposalDetails.timelineMode || proposalDetails.timeline_mode,
expectedCompletionDate: proposalDetails.expectedCompletionDate || proposalDetails.expected_completion_date, expectedCompletionDate: expectedCompletionDate,
expectedCompletionDays: proposalDetails.expectedCompletionDays || proposalDetails.expected_completion_days, expectedCompletionDays: proposalDetails.expectedCompletionDays || proposalDetails.expected_completion_days,
timelineForClosure: expectedCompletionDate, // Map expectedCompletionDate to timelineForClosure for ProposalDetailsCard
dealerComments: proposalDetails.dealerComments || proposalDetails.dealer_comments, dealerComments: proposalDetails.dealerComments || proposalDetails.dealer_comments,
submittedAt: proposalDetails.submittedAt || proposalDetails.submitted_at, submittedOn: proposalDetails.submittedAt || proposalDetails.submitted_at || proposalDetails.submittedOn,
} : undefined; } : undefined;
// Map IO details from dedicated internal_orders table // Map IO details from dedicated internal_orders table