534 lines
22 KiB
TypeScript
534 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 } 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 { Textarea } from '@/components/ui/textarea';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { DollarSign, Download, CircleCheckBig, Target } 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;
|
|
ioRemark?: string; // IO remark
|
|
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
|
|
const internalOrder = apiRequest?.internalOrder || apiRequest?.internal_order || null;
|
|
const existingIONumber = internalOrder?.ioNumber || internalOrder?.io_number || request?.ioNumber || '';
|
|
const existingIORemark = internalOrder?.ioRemark || internalOrder?.io_remark || '';
|
|
const existingBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
|
|
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 [ioRemark, setIoRemark] = useState(existingIORemark);
|
|
const [fetchingAmount, setFetchingAmount] = useState(false);
|
|
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
|
|
const [amountToBlock, setAmountToBlock] = useState<string>('');
|
|
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
|
|
const [blockingBudget, setBlockingBudget] = useState(false);
|
|
|
|
const maxIoRemarkChars = 300;
|
|
const ioRemarkChars = ioRemark.length;
|
|
|
|
// Load existing IO block details from apiRequest
|
|
useEffect(() => {
|
|
if (internalOrder && existingIONumber) {
|
|
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';
|
|
|
|
// Set IO number and remark from existing data
|
|
setIoNumber(existingIONumber);
|
|
setIoRemark(existingIORemark);
|
|
|
|
// Only set blocked details if amount is blocked
|
|
if (existingBlockedAmount > 0) {
|
|
setBlockedDetails({
|
|
ioNumber: existingIONumber,
|
|
blockedAmount: Number(existingBlockedAmount) || 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,
|
|
ioRemark: existingIORemark,
|
|
status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
|
|
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
|
|
});
|
|
|
|
// Set fetched amount if available balance exists
|
|
if (availableBeforeBlock > 0) {
|
|
setFetchedAmount(availableBeforeBlock);
|
|
}
|
|
}
|
|
}
|
|
}, [internalOrder, existingIONumber, existingIORemark, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
|
|
|
|
/**
|
|
* 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);
|
|
// 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('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);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Save IO details (IO number and remark) without blocking budget
|
|
*/
|
|
const handleSaveIODetails = async () => {
|
|
if (!ioNumber.trim()) {
|
|
toast.error('Please enter an IO number');
|
|
return;
|
|
}
|
|
|
|
if (!ioRemark.trim()) {
|
|
toast.error('Please enter an IO remark');
|
|
return;
|
|
}
|
|
|
|
if (!requestId) {
|
|
toast.error('Request ID not found');
|
|
return;
|
|
}
|
|
|
|
setBlockingBudget(true);
|
|
try {
|
|
// Save only IO number and remark (no balance fields)
|
|
const payload = {
|
|
ioNumber: ioNumber.trim(),
|
|
ioRemark: ioRemark.trim(),
|
|
};
|
|
|
|
await updateIODetails(requestId, payload);
|
|
|
|
toast.success('IO details saved successfully');
|
|
|
|
// Refresh request details
|
|
onRefresh?.();
|
|
} catch (error: any) {
|
|
console.error('Failed to save IO details:', error);
|
|
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to save IO details';
|
|
toast.error(errorMessage);
|
|
} finally {
|
|
setBlockingBudget(false);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Block budget in SAP system
|
|
*/
|
|
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 2 decimal places to avoid floating point precision issues
|
|
// This ensures we send clean values like 240.00 instead of 239.9999999
|
|
const blockAmount = Math.round(blockAmountRaw * 100) / 100;
|
|
|
|
if (blockAmount > fetchedAmount) {
|
|
toast.error('Amount to block exceeds available IO budget');
|
|
return;
|
|
}
|
|
|
|
// Log the amount being sent to backend for debugging
|
|
console.log('[IOTab] Blocking budget:', {
|
|
ioNumber: ioNumber.trim(),
|
|
originalInput: amountToBlock,
|
|
parsedAmount: blockAmountRaw,
|
|
roundedAmount: blockAmount,
|
|
fetchedAmount,
|
|
calculatedRemaining: fetchedAmount - blockAmount,
|
|
});
|
|
|
|
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
|
|
const payload = {
|
|
ioNumber: ioNumber.trim(),
|
|
ioRemark: ioRemark.trim(),
|
|
ioAvailableBalance: fetchedAmount,
|
|
ioBlockedAmount: blockAmount,
|
|
ioRemainingBalance: fetchedAmount - blockAmount, // Calculated value (backend will use SAP's actual value)
|
|
};
|
|
|
|
console.log('[IOTab] Sending to backend:', payload);
|
|
|
|
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 || (fetchedAmount - blockAmount));
|
|
|
|
// Log what was saved vs what we sent
|
|
console.log('[IOTab] Blocking result:', {
|
|
sentAmount: blockAmount,
|
|
savedBlockedAmount,
|
|
sentRemaining: fetchedAmount - blockAmount,
|
|
savedRemainingBalance,
|
|
availableBalance: fetchedAmount,
|
|
difference: savedBlockedAmount - blockAmount,
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
|
|
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 savedIoRemark = updatedInternalOrder.ioRemark || updatedInternalOrder.io_remark || ioRemark.trim();
|
|
|
|
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 || '',
|
|
ioRemark: savedIoRemark,
|
|
status: 'blocked',
|
|
};
|
|
|
|
setBlockedDetails(blocked);
|
|
setAmountToBlock(''); // Clear the input
|
|
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 || !!blockedDetails}
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
onClick={handleFetchAmount}
|
|
disabled={!ioNumber.trim() || fetchingAmount || !!blockedDetails}
|
|
className="bg-[#2d4a3e] hover:bg-[#1f3329]"
|
|
>
|
|
<Download className="w-4 h-4 mr-2" />
|
|
{fetchingAmount ? 'Fetching...' : 'Fetch Amount'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* IO Remark Input */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="ioRemark" className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
|
IO Remark <span className="text-red-500">*</span>
|
|
</Label>
|
|
<Textarea
|
|
id="ioRemark"
|
|
placeholder="Enter remarks about IO organization"
|
|
value={ioRemark}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
if (value.length <= maxIoRemarkChars) {
|
|
setIoRemark(value);
|
|
}
|
|
}}
|
|
rows={3}
|
|
disabled={!!blockedDetails}
|
|
className="bg-white text-sm min-h-[80px] resize-none"
|
|
required
|
|
/>
|
|
<div className="flex justify-end text-xs text-gray-600">
|
|
<span>{ioRemarkChars}/{maxIoRemarkChars}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Save IO Details Button (shown when IO number is entered but amount not fetched) */}
|
|
{!fetchedAmount && !blockedDetails && ioNumber.trim() && (
|
|
<Button
|
|
onClick={handleSaveIODetails}
|
|
disabled={blockingBudget || !ioNumber.trim() || !ioRemark.trim()}
|
|
variant="outline"
|
|
className="w-full border-[#2d4a3e] text-[#2d4a3e] hover:bg-[#2d4a3e] hover:text-white"
|
|
>
|
|
{blockingBudget ? 'Saving...' : 'Save IO Details'}
|
|
</Button>
|
|
)}
|
|
|
|
{/* Fetched Amount Display */}
|
|
{fetchedAmount !== null && !blockedDetails && (
|
|
<>
|
|
<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>
|
|
</div>
|
|
|
|
{/* Block Button */}
|
|
<Button
|
|
onClick={handleBlockBudget}
|
|
disabled={blockingBudget || !amountToBlock || parseFloat(amountToBlock) <= 0 || parseFloat(amountToBlock) > fetchedAmount}
|
|
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>
|
|
{blockedDetails ? (
|
|
<div className="space-y-4">
|
|
{/* Success Banner */}
|
|
<div className="bg-green-50 border-2 border-green-500 rounded-lg p-4">
|
|
<div className="flex items-start gap-3">
|
|
<CircleCheckBig className="w-6 h-6 text-green-600 flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="font-semibold text-green-900">IO Blocked Successfully</p>
|
|
<p className="text-sm text-green-700 mt-1">Budget has been reserved in SAP system</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Blocked Details */}
|
|
<div className="border rounded-lg divide-y">
|
|
<div className="p-4">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Number</p>
|
|
<p className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</p>
|
|
</div>
|
|
<div className="p-4">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">SAP Document Number</p>
|
|
<p className="text-sm font-semibold text-gray-900">{blockedDetails.sapDocumentNumber || 'N/A'}</p>
|
|
</div>
|
|
{blockedDetails.ioRemark && (
|
|
<div className="p-4">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Remark</p>
|
|
<p className="text-sm font-medium text-gray-900 whitespace-pre-wrap">{blockedDetails.ioRemark}</p>
|
|
</div>
|
|
)}
|
|
<div className="p-4 bg-green-50">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p>
|
|
<p className="text-xl font-bold text-green-700">
|
|
₹{blockedDetails.blockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
</p>
|
|
</div>
|
|
<div className="p-4">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Available Amount (Before Block)</p>
|
|
<p className="text-sm font-medium text-gray-900">
|
|
₹{blockedDetails.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
</p>
|
|
</div>
|
|
<div className="p-4 bg-blue-50">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Remaining Amount (After Block)</p>
|
|
<p className="text-sm font-bold text-blue-700">
|
|
₹{blockedDetails.remainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
</p>
|
|
</div>
|
|
<div className="p-4">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked By</p>
|
|
<p className="text-sm font-medium text-gray-900">{blockedDetails.blockedBy}</p>
|
|
</div>
|
|
<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 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>
|
|
);
|
|
}
|