diff --git a/src/App.tsx b/src/App.tsx index f279a75..d99ca00 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,16 +24,9 @@ import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal'; import { Toaster } from '@/components/ui/sonner'; import { toast } from 'sonner'; import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase'; -import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase'; import { AuthCallback } from '@/pages/Auth/AuthCallback'; import { createClaimRequest } from '@/services/dealerClaimApi'; - -// 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 -}; +import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal'; interface AppProps { onLogout?: () => void; @@ -62,6 +55,20 @@ function AppRoutes({ onLogout }: AppProps) { const [dynamicRequests, setDynamicRequests] = useState([]); const [selectedRequestId, setSelectedRequestId] = useState(''); const [selectedRequestTitle, setSelectedRequestTitle] = useState(''); + 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 useEffect(() => { @@ -266,7 +273,7 @@ function AppRoutes({ onLogout }: AppProps) { setApprovalAction(null); }; - const handleClaimManagementSubmit = async (claimData: any) => { + const handleClaimManagementSubmit = async (claimData: any, selectedManagerEmail?: string) => { try { // Prepare payload for API const payload = { @@ -283,12 +290,17 @@ function AppRoutes({ onLogout }: AppProps) { periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined, periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined, estimatedBudget: claimData.estimatedBudget || undefined, + selectedManagerEmail: selectedManagerEmail || undefined, }; // Call API to create claim request const response = await createClaimRequest(payload); const createdRequest = response.request; + // Close manager modal if open + setManagerModalOpen(false); + setManagerModalData(null); + toast.success('Claim Request Submitted', { description: 'Your claim management request has been created successfully.', }); @@ -301,6 +313,36 @@ function AppRoutes({ onLogout }: AppProps) { } } catch (error: any) { 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'; toast.error('Failed to Submit Claim Request', { description: errorMessage, @@ -719,6 +761,27 @@ function AppRoutes({ onLogout }: AppProps) { }} /> + {/* Manager Selection Modal */} + { + 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 */} {approvalAction && ( 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 ( + + + + + {errorType === 'NO_MANAGER_FOUND' ? ( + <> + + Manager Not Found + + ) : ( + <> + + Select Your Manager + + )} + + + {errorType === 'NO_MANAGER_FOUND' ? ( +
+

+ {message || 'No reporting manager found in the system. Please ensure your manager is correctly configured in your profile.'} +

+

+ Please contact your administrator to update your manager information, or try again later. +

+
+ ) : ( +
+

+ {message || 'Multiple managers were found with the same name. Please select the correct manager from the list below.'} +

+
+ )} +
+
+ +
+ {errorType === 'NO_MANAGER_FOUND' ? ( +
+
+ +
+

+ Unable to Proceed +

+

+ We couldn't find your reporting manager in the system. The claim request cannot be created without a valid manager assigned. +

+
+
+
+ ) : ( +
+ {managers.map((manager) => ( +
!isLoading && handleSelect(manager.email)} + > +
+
+
+ +
+
+
+

+ {manager.displayName || `${manager.firstName || ''} ${manager.lastName || ''}`.trim() || 'Unknown'} +

+
+
+
+ + {manager.email} +
+ {manager.department && ( +
+ + {manager.department} +
+ )} +
+
+
+ +
+
+ ))} +
+ )} +
+ +
+ {errorType === 'NO_MANAGER_FOUND' ? ( + + ) : ( + <> + + + )} +
+
+
+ ); +} + diff --git a/src/pages/RequestDetail/RequestDetail.tsx b/src/pages/RequestDetail/RequestDetail.tsx index 8a88e9c..1066f9f 100644 --- a/src/pages/RequestDetail/RequestDetail.tsx +++ b/src/pages/RequestDetail/RequestDetail.tsx @@ -151,23 +151,55 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests (level.levelNumber || level.level_number) === 3 ); 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) + // Department lead can access IO tab when they are the current approver for step 3 (to fetch and block IO) const showIOTab = isClaimManagementRequest(apiRequest) && - (isUserInitiator || isDeptLead); + (isUserInitiator || isDeptLead || isStep3CurrentApprover); // Debug logging for troubleshooting console.debug('[RequestDetail] IO Tab visibility:', { isClaimManagement: isClaimManagementRequest(apiRequest), isUserInitiator, isDeptLead, + isStep3CurrentApprover, currentUserId, currentUserEmail, initiatorUserId, 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, + deptLeadEmail, + emailMatch: deptLeadEmail && currentUserEmail ? deptLeadEmail === currentUserEmail : false, showIOTab, }); diff --git a/src/pages/RequestDetail/components/claim-cards/ProposalDetailsCard.tsx b/src/pages/RequestDetail/components/claim-cards/ProposalDetailsCard.tsx index 402c98b..111cacf 100644 --- a/src/pages/RequestDetail/components/claim-cards/ProposalDetailsCard.tsx +++ b/src/pages/RequestDetail/components/claim-cards/ProposalDetailsCard.tsx @@ -27,6 +27,26 @@ interface 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) => { if (amount === undefined || amount === null || Number.isNaN(amount)) { return '₹0.00'; @@ -99,7 +119,7 @@ export function ProposalDetailsCard({ proposalDetails, className }: ProposalDeta Estimated Budget (Total) - {formatCurrency(proposalDetails.estimatedBudgetTotal)} + {formatCurrency(estimatedTotal)} diff --git a/src/pages/RequestDetail/components/modals/EmailNotificationTemplateModal.tsx b/src/pages/RequestDetail/components/modals/EmailNotificationTemplateModal.tsx new file mode 100644 index 0000000..bd4951b --- /dev/null +++ b/src/pages/RequestDetail/components/modals/EmailNotificationTemplateModal.tsx @@ -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 ( + + + +
+
+
+ +
+
+ + Email Notification Template + + + Step {stepNumber}: {stepName} + +
+
+
+
+ +
+ {/* Email Header Section */} +
+
+
+ +
+

To:

+

{recipientEmail}

+
+
+
+ +
+

Subject:

+

{finalSubject}

+
+
+
+
+ + {/* Email Body Section */} +
+
+ {/* Company Header */} +
+ + Royal Enfield +
+ + {/* Email Content */} +
+
+                  {finalEmailBody}
+                
+
+ + {/* Footer */} +
+
+ + Automated email • Royal Enfield Claims Portal +
+
+
+
+ + {/* Badges */} +
+ + Step {stepNumber} + + + Auto-triggered + +
+
+ + {/* Footer */} +
+ +
+
+
+ ); +} + diff --git a/src/pages/RequestDetail/components/tabs/DealerClaimWorkflowTab.tsx b/src/pages/RequestDetail/components/tabs/DealerClaimWorkflowTab.tsx index 0de8a9e..635741b 100644 --- a/src/pages/RequestDetail/components/tabs/DealerClaimWorkflowTab.tsx +++ b/src/pages/RequestDetail/components/tabs/DealerClaimWorkflowTab.tsx @@ -20,6 +20,7 @@ import { InitiatorProposalApprovalModal } from '../modals/InitiatorProposalAppro import { DeptLeadIOApprovalModal } from '../modals/DeptLeadIOApprovalModal'; import { DealerCompletionDocumentsModal } from '../modals/DealerCompletionDocumentsModal'; import { CreditNoteSAPModal } from '../modals/CreditNoteSAPModal'; +import { EmailNotificationTemplateModal } from '../modals/EmailNotificationTemplateModal'; import { toast } from 'sonner'; import { submitProposal, updateIODetails, submitCompletion, updateEInvoice } from '@/services/dealerClaimApi'; import { getWorkflowDetails, approveLevel, rejectLevel } from '@/services/workflowApi'; @@ -161,6 +162,8 @@ export function DealerClaimWorkflowTab({ const [showIOApprovalModal, setShowIOApprovalModal] = useState(false); const [showCompletionModal, setShowCompletionModal] = 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 const [approvalFlow, setApprovalFlow] = useState([]); @@ -895,14 +898,17 @@ export function DealerClaimWorkflowTab({ {step.status.toLowerCase()} - {/* Email Template Button (Step 4) */} - {step.step === 4 && step.emailTemplateUrl && ( + {/* Email Template Button (Step 4) - Show when approved */} + {step.step === 4 && step.status === 'approved' && ( @@ -1235,6 +1241,19 @@ export function DealerClaimWorkflowTab({ requestId={request?.requestId || request?.id} dueDate={request?.dueDate} /> + + {/* Email Notification Template Modal */} + { + 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" + /> ); } diff --git a/src/pages/RequestDetail/components/tabs/IOTab.tsx b/src/pages/RequestDetail/components/tabs/IOTab.tsx index 756d968..f1d54e8 100644 --- a/src/pages/RequestDetail/components/tabs/IOTab.tsx +++ b/src/pages/RequestDetail/components/tabs/IOTab.tsx @@ -13,9 +13,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; 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 { updateIODetails, getClaimDetails } from '@/services/dealerClaimApi'; +import { validateIO, updateIODetails, getClaimDetails } from '@/services/dealerClaimApi'; +import { useAuth } from '@/contexts/AuthContext'; interface IOTabProps { request: any; @@ -26,13 +28,16 @@ interface IOTabProps { interface IOBlockedDetails { ioNumber: string; blockedAmount: number; - availableBalance: number; + availableBalance: number; // Available amount before block + remainingBalance: number; // Remaining amount after block blockedDate: string; + blockedBy: string; // User who blocked sapDocumentNumber: string; status: 'blocked' | 'released' | 'failed'; } export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) { + const { user } = useAuth(); const requestId = apiRequest?.requestId || request?.requestId; // 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 existingRemainingBalance = internalOrder?.ioRemainingBalance || internalOrder?.io_remaining_balance || 0; 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 [fetchingAmount, setFetchingAmount] = useState(false); const [fetchedAmount, setFetchedAmount] = useState(null); + const [amountToBlock, setAmountToBlock] = useState(''); const [blockedDetails, setBlockedDetails] = useState(null); const [blockingBudget, setBlockingBudget] = useState(false); // Load existing IO block details from apiRequest 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({ ioNumber: existingIONumber, 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(), + blockedBy: blockedByName, sapDocumentNumber: sapDocNumber, status: (internalOrder.status === 'BLOCKED' ? 'blocked' : internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed', @@ -64,16 +84,16 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) { setIoNumber(existingIONumber); // Set fetched amount if available balance exists - if (existingAvailableBalance > 0) { - setFetchedAmount(Number(existingAvailableBalance)); + if (availableBeforeBlock > 0) { + setFetchedAmount(availableBeforeBlock); } } - }, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber]); + }, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]); /** * Fetch available budget from SAP - * Validates IO number and gets available balance - * Calls updateIODetails with blockedAmount=0 to validate without blocking + * Validates IO number and gets available balance (returns dummy data for now) + * Does not store anything in database - only validates */ const handleFetchAmount = async () => { if (!ioNumber.trim()) { @@ -88,32 +108,18 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) { setFetchingAmount(true); try { - // Validate IO number by calling updateIODetails with blockedAmount=0 - // This validates the IO with SAP and returns available balance without blocking - // 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 - const claimData = await getClaimDetails(requestId); - const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order; + // Call validate IO endpoint - returns dummy data for now, will integrate with SAP later + const ioData = await validateIO(requestId, ioNumber.trim()); - if (updatedInternalOrder) { - 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); - } + if (ioData.isValid && ioData.availableBalance > 0) { + setFetchedAmount(ioData.availableBalance); + // Pre-fill amount to block with available balance + setAmountToBlock(String(ioData.availableBalance)); + toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`); } else { - toast.error('Failed to fetch IO details after validation'); + toast.error('Invalid IO number or no available balance found'); setFetchedAmount(null); + setAmountToBlock(''); } } catch (error: any) { console.error('Failed to fetch IO budget:', error); @@ -139,27 +145,27 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) { return; } - // Get claim amount from budget tracking or proposal details - const claimAmount = apiRequest?.budgetTracking?.proposalEstimatedBudget || - apiRequest?.proposalDetails?.totalEstimatedBudget || - apiRequest?.claimDetails?.estimatedBudget || - request?.claimAmount || - request?.amount || - 0; + const blockAmount = parseFloat(amountToBlock); - if (claimAmount > fetchedAmount) { - toast.error('Claim amount exceeds available IO budget'); + if (!amountToBlock || isNaN(blockAmount) || blockAmount <= 0) { + toast.error('Please enter a valid amount to block'); + return; + } + + if (blockAmount > fetchedAmount) { + toast.error('Amount to block exceeds available IO budget'); return; } setBlockingBudget(true); 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, { ioNumber: ioNumber.trim(), ioAvailableBalance: fetchedAmount, - ioBlockedAmount: claimAmount, - ioRemainingBalance: fetchedAmount - claimAmount, + ioBlockedAmount: blockAmount, + ioRemainingBalance: fetchedAmount - blockAmount, }); // 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; 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 = { ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber, - blockedAmount: Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || claimAmount), - availableBalance: Number(updatedInternalOrder.ioAvailableBalance || updatedInternalOrder.io_available_balance || fetchedAmount), + blockedAmount: Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount), + 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(), + blockedBy: blockedByName, sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '', status: 'blocked', }; setBlockedDetails(blocked); + setAmountToBlock(''); // Clear the input toast.success('IO budget blocked successfully in SAP'); // 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 (
{/* IO Budget Management Card */} @@ -283,49 +294,51 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) { {/* Fetched Amount Display */} {fetchedAmount !== null && !blockedDetails && ( -
-
-
-

Available Budget

-

- ₹{fetchedAmount.toLocaleString('en-IN')} -

+ <> +
+
+
+

Available Amount

+

+ ₹{fetchedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+
+
- -
- -
-
- Claim Amount: - - ₹{claimAmount.toLocaleString('en-IN')} - -
-
- Balance After Block: - - ₹{(fetchedAmount - claimAmount).toLocaleString('en-IN')} - +
+

IO Number: {ioNumber}

+

Fetched from: SAP System

- {claimAmount > fetchedAmount ? ( -
- -

- Insufficient budget! Claim amount exceeds available balance. -

+ {/* Amount to Block Input */} +
+ +
+ + setAmountToBlock(e.target.value)} + className="pl-8" + />
- ) : ( - - )} -
+
+ + {/* Block Button */} + + )} @@ -345,64 +358,69 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) { {blockedDetails ? (
{/* Success Banner */} -
-
-
- -
+
+
+
-

Budget Blocked Successfully!

-

SAP integration completed

+

IO Blocked Successfully

+

Budget has been reserved in SAP system

{/* Blocked Details */} -
-
- IO Number: - {blockedDetails.ioNumber} +
+
+

IO Number

+

{blockedDetails.ioNumber}

-
- Blocked Amount: - - ₹{blockedDetails.blockedAmount.toLocaleString('en-IN')} - +
+

SAP Document Number

+

{blockedDetails.sapDocumentNumber || 'N/A'}

-
- Available Balance: - - ₹{blockedDetails.availableBalance.toLocaleString('en-IN')} - +
+

Blocked Amount

+

+ ₹{blockedDetails.blockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

-
- Blocked Date: - - {new Date(blockedDetails.blockedDate).toLocaleString('en-IN')} - +
+

Available Amount (Before Block)

+

+ ₹{blockedDetails.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

-
- SAP Document No: - - {blockedDetails.sapDocumentNumber} - +
+

Remaining Amount (After Block)

+

+ ₹{blockedDetails.remainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

-
- Status: - - {blockedDetails.status.toUpperCase()} - +
+

Blocked By

+

{blockedDetails.blockedBy}

+
+
+

Blocked At

+

+ {new Date(blockedDetails.blockedDate).toLocaleString('en-IN', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: true + })} +

+
+
+

Status

+ + + Blocked +
- - {/* Release Button */} -
) : (
diff --git a/src/services/dealerClaimApi.ts b/src/services/dealerClaimApi.ts index 707edd7..2665715 100644 --- a/src/services/dealerClaimApi.ts +++ b/src/services/dealerClaimApi.ts @@ -19,6 +19,7 @@ export interface CreateClaimRequestPayload { periodStartDate?: string; // ISO date string periodEndDate?: string; // ISO date string estimatedBudget?: string | number; + selectedManagerEmail?: string; // Optional: When multiple managers found, user selects one } 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 + * Only stores data when blocking amount > 0 */ export async function updateIODetails( requestId: string, diff --git a/src/utils/claimDataMapper.ts b/src/utils/claimDataMapper.ts index 58b3bf9..8d93578 100644 --- a/src/utils/claimDataMapper.ts +++ b/src/utils/claimDataMapper.ts @@ -215,15 +215,17 @@ export function mapToClaimManagementRequest( } // Map proposal details + const expectedCompletionDate = proposalDetails?.expectedCompletionDate || proposalDetails?.expected_completion_date; const proposal = proposalDetails ? { proposalDocumentUrl: proposalDetails.proposalDocumentUrl || proposalDetails.proposal_document_url, costBreakup: proposalDetails.costBreakup || proposalDetails.cost_breakup || [], totalEstimatedBudget: proposalDetails.totalEstimatedBudget || proposalDetails.total_estimated_budget || 0, timelineMode: proposalDetails.timelineMode || proposalDetails.timeline_mode, - expectedCompletionDate: proposalDetails.expectedCompletionDate || proposalDetails.expected_completion_date, + expectedCompletionDate: expectedCompletionDate, expectedCompletionDays: proposalDetails.expectedCompletionDays || proposalDetails.expected_completion_days, + timelineForClosure: expectedCompletionDate, // Map expectedCompletionDate to timelineForClosure for ProposalDetailsCard dealerComments: proposalDetails.dealerComments || proposalDetails.dealer_comments, - submittedAt: proposalDetails.submittedAt || proposalDetails.submitted_at, + submittedOn: proposalDetails.submittedAt || proposalDetails.submitted_at || proposalDetails.submittedOn, } : undefined; // Map IO details from dedicated internal_orders table