/** * Dealer Claim IO Tab * * This component handles IO (Internal Order) management for dealer claims. * Located in: src/dealer-claim/components/request-detail/ */ import { useState, useEffect, useMemo } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { DollarSign, Download, CircleCheckBig, Target, CircleAlert } from 'lucide-react'; import { toast } from 'sonner'; import { validateIO, updateIODetails, getClaimDetails } from '@/services/dealerClaimApi'; import { useAuth } from '@/contexts/AuthContext'; interface IOTabProps { request: any; apiRequest?: any; onRefresh?: () => void; } interface IOBlockedDetails { ioNumber: string; blockedAmount: 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' | 'pending'; } export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) { const { user } = useAuth(); const requestId = apiRequest?.requestId || request?.requestId; // Get organizer user object from association (organizer) or fallback to organizedBy UUID const proposalDetails = apiRequest?.proposalDetails || {}; const claimDetails = apiRequest?.claimDetails || apiRequest || {}; // Calculate total base amount (needed for budget verification as requested) // This is the taxable amount excluding GST const totalBaseAmount = useMemo(() => { const costBreakupRaw = proposalDetails?.costBreakup || claimDetails?.costBreakup || []; const costBreakup = Array.isArray(costBreakupRaw) ? costBreakupRaw : (typeof costBreakupRaw === 'string' ? JSON.parse(costBreakupRaw) : []); if (!Array.isArray(costBreakup) || costBreakup.length === 0) { return Number(claimDetails?.totalProposedTaxableAmount || proposalDetails?.totalEstimatedBudget || proposalDetails?.total_estimated_budget || 0); } return costBreakup.reduce((sum: number, item: any) => { const amount = typeof item === 'object' ? (item.amount || 0) : 0; const quantity = typeof item === 'object' ? (item.quantity || 1) : 1; return sum + (Number(amount) * Number(quantity)); }, 0); }, [proposalDetails?.costBreakup, claimDetails?.costBreakup, claimDetails?.totalProposedTaxableAmount, proposalDetails?.totalEstimatedBudget]); // Use base amount as the target budget for blocking const estimatedBudget = totalBaseAmount; // Budget status for signaling (Scenario 2) // Use apiRequest as the primary source of truth, fall back to request const budgetTracking = apiRequest?.budgetTracking || request?.budgetTracking || {}; const budgetStatus = budgetTracking?.budgetStatus || budgetTracking?.budget_status || ''; const internalOrdersList = apiRequest?.internalOrders || apiRequest?.internal_orders || request?.internalOrders || []; const isAdditionalBlockingNeeded = budgetStatus === 'PROPOSED' && internalOrdersList.length > 0; const [ioNumber, setIoNumber] = useState(''); const [fetchingAmount, setFetchingAmount] = useState(false); const [fetchedAmount, setFetchedAmount] = useState(null); const [amountToBlock, setAmountToBlock] = useState(''); const [blockedIOs, setBlockedIOs] = useState([]); const [blockingBudget, setBlockingBudget] = useState(false); // Load existing IO blocks useEffect(() => { if (internalOrdersList.length > 0) { const formattedIOs = internalOrdersList.map((io: any) => { const org = io.organizer || null; const blockedByName = org?.displayName || org?.display_name || org?.name || (org?.firstName && org?.lastName ? `${org.firstName} ${org.lastName}`.trim() : null) || org?.email || 'Unknown User'; return { ioNumber: io.ioNumber || io.io_number, blockedAmount: Number(io.ioBlockedAmount || io.io_blocked_amount || 0), availableBalance: Number(io.ioAvailableBalance || io.io_available_balance || 0), remainingBalance: Number(io.ioRemainingBalance || io.io_remaining_balance || 0), blockedDate: io.organizedAt || io.organized_at || new Date().toISOString(), blockedBy: blockedByName, sapDocumentNumber: io.sapDocumentNumber || io.sap_document_number || '', status: (io.status === 'BLOCKED' ? 'blocked' : io.status === 'RELEASED' ? 'released' : io.status === 'PENDING' ? 'pending' : 'blocked') as any, }; }); setBlockedIOs(formattedIOs); // If we are not in Scenario 2 (additional blocking), set the IO number from the last block for convenience if (!isAdditionalBlockingNeeded && formattedIOs.length > 0) { setIoNumber(formattedIOs[formattedIOs.length - 1].ioNumber); } } }, [apiRequest, request, isAdditionalBlockingNeeded, internalOrdersList]); /** * Fetch available budget from SAP * 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()) { toast.error('Please enter an IO number'); return; } if (!requestId) { toast.error('Request ID not found'); return; } setFetchingAmount(true); try { // Call validate IO endpoint - returns dummy data for now, will integrate with SAP later const ioData = await validateIO(requestId, ioNumber.trim()); if (ioData.isValid && ioData.availableBalance > 0) { setFetchedAmount(ioData.availableBalance); // Calculate total already blocked amount const totalAlreadyBlocked = blockedIOs.reduce((sum, io) => sum + io.blockedAmount, 0); // Calculate remaining budget to block const remainingToBlock = Math.max(0, estimatedBudget - totalAlreadyBlocked); // Pre-fill amount to block with remaining budget, otherwise use available balance if (remainingToBlock > 0) { setAmountToBlock(String(remainingToBlock.toFixed(2))); } else if (estimatedBudget > 0 && totalAlreadyBlocked === 0) { setAmountToBlock(String(estimatedBudget.toFixed(2))); } else { setAmountToBlock(String(ioData.availableBalance.toFixed(2))); } toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`); } else { toast.error('Invalid IO number or no available balance found'); setFetchedAmount(null); setAmountToBlock(''); } } catch (error: any) { console.error('Failed to fetch IO budget:', error); const errorMessage = error?.response?.data?.message || error?.message || 'Failed to validate IO number or fetch budget from SAP'; toast.error(errorMessage); setFetchedAmount(null); } finally { setFetchingAmount(false); } }; /** * Block budget in SAP system * This function: * 1. Validates the IO number and amount * 2. Calls SAP to block the budget * 3. Saves IO number, blocked amount, and balance details to database */ const handleBlockBudget = async () => { if (!ioNumber.trim() || fetchedAmount === null) { toast.error('Please fetch IO amount first'); return; } if (!requestId) { toast.error('Request ID not found'); return; } const blockAmountRaw = parseFloat(amountToBlock); if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 0) { toast.error('Please enter a valid amount to block'); return; } // Round to exactly 2 decimal places to avoid floating point precision issues // Use parseFloat with toFixed to ensure exact 2 decimal precision const blockAmount = parseFloat(blockAmountRaw.toFixed(2)); if (blockAmount > fetchedAmount) { toast.error('Amount to block exceeds available IO budget'); return; } // Calculate total already blocked const totalAlreadyBlocked = blockedIOs.reduce((sum, io) => sum + io.blockedAmount, 0); const totalPlanned = totalAlreadyBlocked + blockAmount; // Validate that total planned must exactly match estimated budget if (estimatedBudget > 0) { const roundedEstimatedBudget = parseFloat(estimatedBudget.toFixed(2)); const roundedTotalPlanned = parseFloat(totalPlanned.toFixed(2)); if (Math.abs(roundedTotalPlanned - roundedEstimatedBudget) > 0.01) { toast.error(`Total blocked amount (₹${roundedTotalPlanned.toLocaleString('en-IN')}) must be exactly equal to the estimated budget (₹${roundedEstimatedBudget.toLocaleString('en-IN')})`); return; } } // Blocking budget setBlockingBudget(true); try { // Call updateIODetails with blockedAmount to block budget in SAP and store in database // This will store in internal_orders and claim_budget_tracking tables // Note: Backend will recalculate remainingBalance from SAP's response, so we pass it for reference only // Ensure all amounts are rounded to 2 decimal places for consistency const roundedFetchedAmount = parseFloat(fetchedAmount.toFixed(2)); const calculatedRemaining = parseFloat((roundedFetchedAmount - blockAmount).toFixed(2)); const payload = { ioNumber: ioNumber.trim(), ioAvailableBalance: roundedFetchedAmount, ioBlockedAmount: blockAmount, ioRemainingBalance: calculatedRemaining, // Calculated value (backend will use SAP's actual value) }; // Sending to backend await updateIODetails(requestId, payload); // Fetch updated claim details to get the blocked IO data const claimData = await getClaimDetails(requestId); const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order; if (updatedInternalOrder) { const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount); const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || 0); // Calculate expected remaining balance for validation/debugging const expectedRemainingBalance = fetchedAmount - savedBlockedAmount; // Blocking result processed // Warn if the saved amount differs from what we sent if (Math.abs(savedBlockedAmount - blockAmount) > 0.01) { console.warn('[IOTab] ⚠️ Amount mismatch! Sent:', blockAmount, 'Saved:', savedBlockedAmount); } // Warn if remaining balance calculation seems incorrect (for backend debugging) if (Math.abs(savedRemainingBalance - expectedRemainingBalance) > 0.01) { console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', { availableBalance: fetchedAmount, blockedAmount: savedBlockedAmount, expectedRemaining: expectedRemainingBalance, backendRemaining: savedRemainingBalance, difference: savedRemainingBalance - expectedRemainingBalance, }); } 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: savedBlockedAmount, availableBalance: fetchedAmount, // Available amount before block remainingBalance: savedRemainingBalance, blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(), blockedBy: blockedByName, sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '', status: 'blocked', }; setBlockedIOs(prev => [...prev, blocked]); setAmountToBlock(''); // Clear the input setFetchedAmount(null); // Reset fetched state toast.success('IO budget blocked successfully in SAP'); // Refresh request details onRefresh?.(); } else { toast.error('IO blocked but failed to fetch updated details'); onRefresh?.(); } } catch (error: any) { console.error('Failed to block IO budget:', error); const errorMessage = error?.response?.data?.message || error?.message || 'Failed to block IO budget in SAP'; toast.error(errorMessage); } finally { setBlockingBudget(false); } }; return (
{/* IO Budget Management Card */} IO Budget Management Enter IO number to fetch available budget from SAP {/* IO Number Input */}
setIoNumber(e.target.value)} disabled={fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)} className="flex-1" />
{/* Instructions when IO number is entered but not fetched */} {!fetchedAmount && blockedIOs.length === 0 && ioNumber.trim() && (

Next Step: Click "Fetch Amount" to validate the IO number and get available balance from SAP.

)} {/* Fetched Amount Display */} {fetchedAmount !== null && (blockedIOs.length === 0 || isAdditionalBlockingNeeded) && ( <>

Available Amount

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

IO Number: {ioNumber}

Fetched from: SAP System

{/* Amount to Block Input */}
setAmountToBlock(e.target.value)} className="pl-8" />
{estimatedBudget > 0 && (

Required: Amount must be exactly equal to the estimated budget: ₹{estimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}

)}
{/* Block Button */} )}
{/* IO Blocked Details Card */} IO Blocked Details Details of IO blocked in SAP system {blockedIOs.length > 0 ? (
{isAdditionalBlockingNeeded && (

Additional Budget Blocking Required

Actual expenses exceed the previously blocked amount. Please block an additional ₹{(estimatedBudget - blockedIOs.reduce((s, i) => s + i.blockedAmount, 0)).toLocaleString('en-IN', { minimumFractionDigits: 2 })}.

)} {blockedIOs.slice().reverse().map((io, idx) => (
IO: {io.ioNumber} {io.status === 'blocked' ? 'Blocked' : io.status === 'pending' ? 'Provisioned' : 'Released'}

Amount

₹{io.blockedAmount.toLocaleString('en-IN')}

SAP Doc

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

Blocked By

{io.blockedBy}

Date

{new Date(io.blockedDate).toLocaleString()}

))}
Total Blocked: ₹{blockedIOs.reduce((s, i) => s + i.blockedAmount, 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}
) : (

No IO blocked yet

Enter IO number and fetch amount to block budget

)}
); }