Re_Figma_Code/src/dealer-claim/components/request-detail/IOTab.tsx

500 lines
22 KiB
TypeScript

/**
* 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<number | null>(null);
const [amountToBlock, setAmountToBlock] = useState<string>('');
const [blockedIOs, setBlockedIOs] = useState<IOBlockedDetails[]>([]);
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 (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* IO Budget Management Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="w-5 h-5 text-[#2d4a3e]" />
IO Budget Management
</CardTitle>
<CardDescription>
Enter IO number to fetch available budget from SAP
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* IO Number Input */}
<div className="space-y-3">
<Label htmlFor="ioNumber">IO Number *</Label>
<div className="flex gap-2">
<Input
id="ioNumber"
placeholder="Enter IO number (e.g., IO-2024-12345)"
value={ioNumber}
onChange={(e) => setIoNumber(e.target.value)}
disabled={fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)}
className="flex-1"
/>
<Button
onClick={handleFetchAmount}
disabled={!ioNumber.trim() || fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)}
className="bg-[#2d4a3e] hover:bg-[#1f3329]"
>
<Download className="w-4 h-4 mr-2" />
{fetchingAmount ? 'Fetching...' : 'Fetch Amount'}
</Button>
</div>
</div>
{/* Instructions when IO number is entered but not fetched */}
{!fetchedAmount && blockedIOs.length === 0 && ioNumber.trim() && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800">
<strong>Next Step:</strong> Click "Fetch Amount" to validate the IO number and get available balance from SAP.
</p>
</div>
)}
{/* Fetched Amount Display */}
{fetchedAmount !== null && (blockedIOs.length === 0 || isAdditionalBlockingNeeded) && (
<>
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-600 uppercase tracking-wide mb-1">Available Amount</p>
<p className="text-2xl font-bold text-green-700">
{fetchedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<CircleCheckBig className="w-8 h-8 text-green-600" />
</div>
<div className="mt-3 pt-3 border-t border-green-200">
<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>
</div>
{/* Amount to Block Input */}
<div className="space-y-3">
<Label htmlFor="blockAmount">Amount to Block *</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"></span>
<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>
{estimatedBudget > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-xs text-amber-800">
<strong>Required:</strong> Amount must be exactly equal to the estimated budget: <strong>{estimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong>
</p>
</div>
)}
</div>
{/* Block Button */}
<Button
onClick={handleBlockBudget}
disabled={
blockingBudget ||
!amountToBlock ||
parseFloat(amountToBlock) <= 0 ||
parseFloat(amountToBlock) > fetchedAmount ||
(estimatedBudget > 0 && Math.abs((blockedIOs.reduce((s, i) => s + i.blockedAmount, 0) + parseFloat(amountToBlock)) - estimatedBudget) > 0.01)
}
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
>
<Target className="w-4 h-4 mr-2" />
{blockingBudget ? 'Blocking in SAP...' : 'Block IO in SAP'}
</Button>
</>
)}
</CardContent>
</Card>
{/* IO Blocked Details Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CircleCheckBig className="w-5 h-5 text-green-600" />
IO Blocked Details
</CardTitle>
<CardDescription>
Details of IO blocked in SAP system
</CardDescription>
</CardHeader>
<CardContent>
{blockedIOs.length > 0 ? (
<div className="space-y-6">
{isAdditionalBlockingNeeded && (
<div className="bg-amber-50 border-2 border-amber-500 rounded-lg p-4 animate-pulse">
<div className="flex items-start gap-3">
<CircleAlert className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-amber-900">Additional Budget Blocking Required</p>
<p className="text-sm text-amber-700 mt-1">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 })}.</p>
</div>
</div>
</div>
)}
{blockedIOs.slice().reverse().map((io, idx) => (
<div key={idx} className="border rounded-lg overflow-hidden">
<div className={`p-3 flex justify-between items-center ${idx === 0 ? 'bg-green-50' : 'bg-gray-50'}`}>
<span className="font-semibold text-sm">IO: {io.ioNumber}</span>
<Badge className={
io.status === 'blocked' ? 'bg-green-100 text-green-800' :
io.status === 'pending' ? 'bg-amber-100 text-amber-800' :
'bg-blue-100 text-blue-800'
}>
{io.status === 'blocked' ? 'Blocked' :
io.status === 'pending' ? 'Provisioned' : 'Released'}
</Badge>
</div>
<div className="grid grid-cols-2 divide-x divide-y">
<div className="p-3">
<p className="text-[10px] text-gray-500 uppercase">Amount</p>
<p className="text-sm font-bold text-green-700">{io.blockedAmount.toLocaleString('en-IN')}</p>
</div>
<div className="p-3">
<p className="text-[10px] text-gray-500 uppercase">SAP Doc</p>
<p className="text-sm font-medium">{io.sapDocumentNumber || 'N/A'}</p>
</div>
<div className="p-3">
<p className="text-[10px] text-gray-500 uppercase">Blocked By</p>
<p className="text-xs">{io.blockedBy}</p>
</div>
<div className="p-3">
<p className="text-[10px] text-gray-500 uppercase">Date</p>
<p className="text-[10px]">{new Date(io.blockedDate).toLocaleString()}</p>
</div>
</div>
</div>
))}
<div className="mt-4 p-4 bg-[#2d4a3e] text-white rounded-lg flex justify-between items-center">
<span className="font-bold">Total Blocked:</span>
<span className="text-xl font-bold">{blockedIOs.reduce((s, i) => s + i.blockedAmount, 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}</span>
</div>
</div>
) : (
<div className="text-center py-12">
<DollarSign className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-sm text-gray-500 mb-2">No IO blocked yet</p>
<p className="text-xs text-gray-400">
Enter IO number and fetch amount to block budget
</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}