dealer from external source implemented and re-iteration and multiple io block implemented need to test end to end

This commit is contained in:
laxmanhalaki 2026-03-02 21:31:40 +05:30
parent b04776a5f8
commit e11f13d248
7 changed files with 299 additions and 232 deletions

View File

@ -29,7 +29,7 @@ import {
} from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { getAllDealers as fetchDealersFromAPI, verifyDealerLogin, type DealerInfo } from '@/services/dealerApi';
import { verifyDealerLogin, searchExternalDealerByCode, type DealerInfo } from '@/services/dealerApi';
import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep';
import { useAuth } from '@/contexts/AuthContext';
import { getPublicConfigurations, AdminConfiguration, getActivityTypes, type ActivityType } from '@/services/adminApi';
@ -68,7 +68,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
const dealerSearchTimer = useRef<any>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const submitTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// System policy state
const [systemPolicy, setSystemPolicy] = useState({
maxApprovalLevels: 10,
@ -76,7 +76,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
allowSpectators: true,
maxSpectators: 20
});
const [policyViolationModal, setPolicyViolationModal] = useState<{
open: boolean;
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
@ -140,7 +140,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
}
};
}, []);
const [formData, setFormData] = useState({
activityName: '',
activityType: '',
@ -175,7 +175,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
// Handle dealer search input with debouncing
const handleDealerSearchInputChange = (value: string) => {
setDealerSearchInput(value);
// Clear previous timer
if (dealerSearchTimer.current) {
clearTimeout(dealerSearchTimer.current);
@ -194,10 +194,26 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
// Debounce search
dealerSearchTimer.current = setTimeout(async () => {
try {
const results = await fetchDealersFromAPI(value, 10); // Limit to 10 results
setDealerSearchResults(results);
const result = await searchExternalDealerByCode(value);
if (result) {
// Map external API response to DealerInfo structure
const mappedDealer: DealerInfo = {
dealerId: result.dealer || result.dealer_code || value,
dealerCode: result.dealer || result.dealer_code || value,
dealerName: result['dealer name'] || result.dealer_name || 'Unknown Dealer',
displayName: result['dealer name'] || result.dealer_name || 'Unknown Dealer',
email: result['dealer email'] || '',
phone: result['dealer phone'] || '',
city: result['re city'] || result.city || '',
state: result['re state code'] || result.state || '',
isLoggedIn: true, // We'll verify this in the next step
};
setDealerSearchResults([mappedDealer]);
} else {
setDealerSearchResults([]);
}
} catch (error) {
console.error('Error searching dealers:', error);
console.error('Error searching external dealer:', error);
setDealerSearchResults([]);
} finally {
setDealerSearchLoading(false);
@ -208,7 +224,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
const updateFormData = (field: string, value: any) => {
setFormData(prev => {
const updated = { ...prev, [field]: value };
// Validate period dates
if (field === 'periodStartDate') {
// If start date is selected and end date exists, validate end date
@ -225,7 +241,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
return prev;
}
}
return updated;
});
};
@ -233,18 +249,18 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
const isStepValid = () => {
switch (currentStep) {
case 1:
return formData.activityName &&
formData.activityType &&
formData.dealerCode &&
formData.dealerName &&
formData.activityDate &&
formData.location &&
formData.requestDescription;
return formData.activityName &&
formData.activityType &&
formData.dealerCode &&
formData.dealerName &&
formData.activityDate &&
formData.location &&
formData.requestDescription;
case 2:
// Validate that all required approvers are assigned (Step 3 only, Step 8 is now system/Finance)
const approvers = formData.approvers || [];
// Find step 3 approver by originalStepLevel first, then fallback to level
const step3Approver = approvers.find((a: any) =>
const step3Approver = approvers.find((a: any) =>
a.originalStepLevel === 3 || (!a.originalStepLevel && a.level === 3 && !a.isAdditional)
);
// Step 8 is now a system step, no validation needed
@ -263,15 +279,15 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
if (currentStep === 2) {
const approvers = formData.approvers || [];
// Find step 3 approver by originalStepLevel first, then fallback to level
const step3Approver = approvers.find((a: any) =>
const step3Approver = approvers.find((a: any) =>
a.originalStepLevel === 3 || (!a.originalStepLevel && a.level === 3 && !a.isAdditional)
);
const missingSteps: string[] = [];
if (!step3Approver?.email || !step3Approver?.userId || !step3Approver?.tat) {
missingSteps.push('Department Lead Approval');
}
if (missingSteps.length > 0) {
toast.error(`Please add missing approvers: ${missingSteps.join(', ')}`);
} else {
@ -297,7 +313,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
setVerifyingDealer(true);
try {
const verifiedDealer = await verifyDealerLogin(selectedDealer.dealerCode);
if (!verifiedDealer.isLoggedIn) {
toast.error(
`Dealer "${verifiedDealer.dealerName || verifiedDealer.displayName}" (${verifiedDealer.dealerCode}) is not mapped to the system.`,
@ -321,14 +337,14 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
updateFormData('dealerEmail', verifiedDealer.email || '');
updateFormData('dealerPhone', verifiedDealer.phone || '');
updateFormData('dealerAddress', ''); // Address not available in API response
// Clear search input and results
setDealerSearchInput(verifiedDealer.dealerName || verifiedDealer.displayName);
setDealerSearchResults([]);
toast.success(`Dealer "${verifiedDealer.dealerName || verifiedDealer.displayName}" verified and mapped to the System`);
} catch (error: any) {
const errorMessage = 'Dealer is not mapped to the system'
const errorMessage = 'Dealer is not mapped to the system'
toast.error(errorMessage, { duration: 5000 });
// Clear the selection
setDealerSearchInput('');
@ -353,11 +369,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
// Just sort them and prepare for submission
const approvers = formData.approvers || [];
const sortedApprovers = [...approvers].sort((a, b) => a.level - b.level);
// Check for duplicate levels (should not happen, but safeguard)
const levelMap = new Map<number, typeof sortedApprovers[0]>();
const duplicates: number[] = [];
sortedApprovers.forEach((approver) => {
if (levelMap.has(approver.level)) {
duplicates.push(approver.level);
@ -365,13 +381,13 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
levelMap.set(approver.level, approver);
}
});
if (duplicates.length > 0) {
toast.error(`Duplicate approver levels detected: ${duplicates.join(', ')}. Please refresh and try again.`);
console.error('Duplicate levels found:', duplicates, sortedApprovers);
return;
}
// Prepare final approvers array - preserve stepName for additional approvers
// The backend will use stepName to set the levelName for approval levels
// Also preserve originalStepLevel so backend can identify which step each approver belongs to
@ -384,18 +400,18 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
tat: approver.tat,
tatType: approver.tatType,
};
// Preserve stepName for additional approvers
if (approver.isAdditional && approver.stepName) {
result.stepName = approver.stepName;
result.isAdditional = true;
}
// Preserve originalStepLevel for fixed steps (so backend can identify which step this is)
if (approver.originalStepLevel) {
result.originalStepLevel = approver.originalStepLevel;
}
return result;
});
@ -486,8 +502,8 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<div>
<Label htmlFor="activityType" className="text-base font-semibold">Activity Type *</Label>
<Select
value={formData.activityType}
<Select
value={formData.activityType}
onValueChange={(value) => updateFormData('activityType', value)}
disabled={loadingActivityTypes}
>
@ -734,7 +750,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
</p>
) : (
<p className="text-xs text-gray-500">
{formData.periodStartDate
{formData.periodStartDate
? 'Please select end date for the period'
: 'Please select start date first'}
</p>
@ -754,9 +770,9 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
currentUserEmail={(user as any)?.email || ''}
currentUserId={(user as any)?.userId || ''}
currentUserName={
(user as any)?.displayName ||
(user as any)?.name ||
((user as any)?.firstName && (user as any)?.lastName
(user as any)?.displayName ||
(user as any)?.name ||
((user as any)?.firstName && (user as any)?.lastName
? `${(user as any).firstName} ${(user as any).lastName}`.trim()
: (user as any)?.email || 'User')
}
@ -857,16 +873,16 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
const sortedApprovers = [...(formData.approvers || [])]
.filter((a: any) => !a.email?.includes('system@') && !a.email?.includes('finance@'))
.sort((a: any, b: any) => a.level - b.level);
return sortedApprovers.map((approver: any) => {
const tat = Number(approver.tat || 0);
const tatType = approver.tatType || 'hours';
const hours = tatType === 'days' ? tat * 24 : tat;
// Find step name - handle additional approvers and shifted levels
let stepName = 'Unknown';
let stepLabel = '';
if (approver.isAdditional) {
// Additional approver - use stepName if available
stepName = approver.stepName || 'Additional Approver';
@ -874,17 +890,16 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
stepLabel = approver.stepName || `Additional Approver (after "${afterStep?.name || 'Unknown'}")`;
} else {
// Fixed step - find by originalStepLevel first, then fallback to level
const step = approver.originalStepLevel
const step = approver.originalStepLevel
? CLAIM_STEPS.find(s => s.level === approver.originalStepLevel)
: CLAIM_STEPS.find(s => s.level === approver.level && !s.isAuto);
stepName = step?.name || 'Unknown';
stepLabel = stepName;
}
return (
<div key={`${approver.level}-${approver.email}`} className={`p-3 rounded-lg border ${
approver.isAdditional ? 'bg-purple-50 border-purple-200' : 'bg-gray-50 border-gray-200'
}`}>
<div key={`${approver.level}-${approver.email}`} className={`p-3 rounded-lg border ${approver.isAdditional ? 'bg-purple-50 border-purple-200' : 'bg-gray-50 border-gray-200'
}`}>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
@ -960,8 +975,8 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Brief Requirement</Label>
<div className="mt-2 p-4 bg-gray-50 rounded-lg border">
<FormattedDescription
content={formData.requestDescription || ''}
<FormattedDescription
content={formData.requestDescription || ''}
className="text-sm"
/>
</div>
@ -1032,7 +1047,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<span className="hidden sm:inline">Back to Templates</span>
<span className="sm:hidden">Back</span>
</Button>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0">
<div>
<Badge variant="secondary" className="mb-2 text-xs">Claim Management Template</Badge>
@ -1048,11 +1063,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<Progress value={(currentStep / totalSteps) * 100} className="h-2" />
<div className="flex justify-between mt-2 px-1">
{STEP_NAMES.map((_name, index) => (
<span
key={index}
className={`text-xs sm:text-sm ${
index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400'
}`}
<span
key={index}
className={`text-xs sm:text-sm ${index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400'
}`}
>
{index + 1}
</span>
@ -1085,11 +1099,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
{currentStep < totalSteps ? (
<Button
onClick={nextStep}
className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${
!isStepValid()
? 'opacity-50 cursor-pointer hover:opacity-60'
: ''
}`}
className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${!isStepValid()
? 'opacity-50 cursor-pointer hover:opacity-60'
: ''
}`}
>
Next
<ArrowRight className="w-4 h-4" />

View File

@ -11,7 +11,7 @@ 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 } from 'lucide-react';
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';
@ -36,89 +36,60 @@ interface IOBlockedDetails {
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 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;
// Get estimated budget from proposal details
// Get estimated budget from proposal details or DealerClaimDetails
const proposalDetails = apiRequest?.proposalDetails || {};
const estimatedBudget = Number(proposalDetails?.totalEstimatedBudget || proposalDetails?.total_estimated_budget || 0);
const [ioNumber, setIoNumber] = useState(existingIONumber);
const claimDetails = apiRequest?.claimDetails || apiRequest || {};
// Use totalProposedTaxableAmount if available (for Scenario 2), fallback to estimated budget
const estimatedBudget = Number(claimDetails?.totalProposedTaxableAmount || proposalDetails?.totalEstimatedBudget || proposalDetails?.total_estimated_budget || 0);
// Budget status for signaling (Scenario 2)
const budgetTracking = apiRequest?.budgetTracking || apiRequest?.budget_tracking || {};
const budgetStatus = budgetTracking?.budgetStatus || budgetTracking?.budget_status || '';
const isAdditionalBlockingNeeded = budgetStatus === 'PROPOSED' && (apiRequest?.internalOrders?.length > 0 || apiRequest?.internal_orders?.length > 0);
const [ioNumber, setIoNumber] = useState('');
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 [blockedIOs, setBlockedIOs] = useState<IOBlockedDetails[]>([]);
const [blockingBudget, setBlockingBudget] = useState(false);
// Load existing IO block details from apiRequest
// Load existing IO blocks
useEffect(() => {
if (internalOrder && existingIONumber) {
// IMPORTANT: ioAvailableBalance is already the available balance BEFORE blocking
// We should NOT add blockedAmount to it - that would cause double deduction
// Backend stores: availableBalance (before block), blockedAmount, remainingBalance (after block)
const availableBeforeBlock = Number(existingAvailableBalance) || 0;
// 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 from existing data
setIoNumber(existingIONumber);
// Only set blocked details if amount is blocked
if (existingBlockedAmount > 0) {
const blockedAmt = Number(existingBlockedAmount) || 0;
const backendRemaining = Number(existingRemainingBalance) || 0;
// Calculate expected remaining balance for validation/debugging
// Formula: remaining = availableBeforeBlock - blockedAmount
const expectedRemaining = availableBeforeBlock - blockedAmt;
// Loading existing IO block
// Warn if remaining balance calculation seems incorrect (for backend debugging)
if (Math.abs(backendRemaining - expectedRemaining) > 0.01) {
console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', {
availableBalance: availableBeforeBlock,
blockedAmount: blockedAmt,
expectedRemaining,
backendRemaining,
difference: backendRemaining - expectedRemaining,
});
}
setBlockedDetails({
ioNumber: existingIONumber,
blockedAmount: blockedAmt,
availableBalance: availableBeforeBlock, // Available amount before block
remainingBalance: backendRemaining, // Use backend calculated value
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
const ios = apiRequest?.internalOrders || apiRequest?.internal_orders || [];
if (ios.length > 0) {
const formattedIOs = ios.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: sapDocNumber,
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);
}
sapDocumentNumber: io.sapDocumentNumber || io.sap_document_number || '',
status: (io.status === 'BLOCKED' ? 'blocked' :
io.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
};
});
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);
}
}
}, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
}, [apiRequest, isAdditionalBlockingNeeded]);
/**
* Fetch available budget from SAP
@ -140,7 +111,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
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 estimated budget (if available), otherwise use available balance
@ -184,26 +155,33 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
}
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;
}
// Validate that amount to block must exactly match estimated budget
// 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));
if (Math.abs(blockAmount - roundedEstimatedBudget) > 0.01) {
toast.error(`Amount to block must be exactly equal to the estimated budget (₹${roundedEstimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 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;
}
}
@ -224,29 +202,29 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
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!', {
@ -257,17 +235,17 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
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 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,
@ -278,11 +256,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
status: 'blocked',
};
setBlockedDetails(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 {
@ -321,12 +300,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
placeholder="Enter IO number (e.g., IO-2024-12345)"
value={ioNumber}
onChange={(e) => setIoNumber(e.target.value)}
disabled={fetchingAmount || !!blockedDetails}
disabled={fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)}
className="flex-1"
/>
<Button
onClick={handleFetchAmount}
disabled={!ioNumber.trim() || fetchingAmount || !!blockedDetails}
disabled={!ioNumber.trim() || fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)}
className="bg-[#2d4a3e] hover:bg-[#1f3329]"
>
<Download className="w-4 h-4 mr-2" />
@ -336,7 +315,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
</div>
{/* Instructions when IO number is entered but not fetched */}
{!fetchedAmount && !blockedDetails && ioNumber.trim() && (
{!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.
@ -345,7 +324,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
)}
{/* Fetched Amount Display */}
{fetchedAmount !== null && !blockedDetails && (
{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">
@ -392,11 +371,11 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
<Button
onClick={handleBlockBudget}
disabled={
blockingBudget ||
!amountToBlock ||
parseFloat(amountToBlock) <= 0 ||
blockingBudget ||
!amountToBlock ||
parseFloat(amountToBlock) <= 0 ||
parseFloat(amountToBlock) > fetchedAmount ||
(estimatedBudget > 0 && Math.abs(parseFloat(amountToBlock) - estimatedBudget) > 0.01)
(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]"
>
@ -420,71 +399,52 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
</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>
{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>
</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>
<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>
{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' : 'bg-blue-100 text-blue-800'}>
{io.status === 'blocked' ? 'Blocked' : '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>
) : (

View File

@ -1094,6 +1094,45 @@ export function DealerClaimWorkflowTab({
}
};
// Handle re-quotation request from Claim Approval step (Step 6)
const handleClaimReQuotation = async (comments: string) => {
try {
if (!request?.id && !request?.requestId) {
throw new Error('Request ID not found');
}
const requestId = request.id || request.requestId;
// Get workflow details to find the Requestor Claim Approval levelId dynamically
const details = await getWorkflowDetails(requestId);
const approvals = details?.approvalLevels || details?.approvals || [];
// Find the Requestor Claim Approval step
const claimApprovalLevel = approvals.find((level: any) => {
const levelName = (level.levelName || level.level_name || '').toLowerCase();
return levelName.includes('requestor claim') || levelName.includes('requestor - claim');
});
if (!claimApprovalLevel?.levelId && !claimApprovalLevel?.level_id) {
throw new Error('Claim approval level not found');
}
const levelId = claimApprovalLevel.levelId || claimApprovalLevel.level_id;
// Reject the claim approval step with 'Revised Quotation Requested'
// This will trigger the backend to return the workflow to the Dealer Proposal step
await rejectLevel(requestId, levelId, 'Revised Quotation Requested', comments);
toast.success('Re-quotation requested. Request returned to dealer.');
handleRefresh();
} catch (error: any) {
console.error('Failed to request re-quotation:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to request re-quotation. Please try again.';
toast.error(errorMessage);
throw error;
}
};
// Handle IO approval (Department Lead step - found dynamically)
const handleIOApproval = async (data: {
ioNumber: string;
@ -2564,6 +2603,7 @@ export function DealerClaimWorkflowTab({
requestTitle={request?.title}
requestNumber={request?.requestNumber || request?.request_number || request?.id}
taxationType={request?.claimDetails?.taxationType}
onReQuotation={handleClaimReQuotation}
/>
{/* Credit Note from SAP Modal (Step 8) */}

View File

@ -27,6 +27,7 @@ import {
Download,
Eye,
Loader2,
RefreshCw,
} from 'lucide-react';
import { toast } from 'sonner';
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
@ -80,6 +81,7 @@ interface DMSPushModalProps {
isOpen: boolean;
onClose: () => void;
onPush: (comments: string) => Promise<void>;
onReQuotation?: (comments: string) => Promise<void>;
completionDetails?: CompletionDetails | null;
ioDetails?: IODetails | null;
completionDocuments?: CompletionDocuments | null;
@ -92,6 +94,7 @@ export function DMSPushModal({
isOpen,
onClose,
onPush,
onReQuotation,
completionDetails,
ioDetails,
completionDocuments,
@ -257,6 +260,30 @@ export function DMSPushModal({
setComments('');
};
const handleReQuotation = async () => {
if (!comments.trim()) {
toast.error('Please provide comments (reason) for re-quotation request');
return;
}
if (!onReQuotation) {
toast.error('Re-quotation handler not provided');
return;
}
try {
setSubmitting(true);
await onReQuotation(comments.trim());
handleReset();
onClose();
} catch (error) {
console.error('Failed to request re-quotation:', error);
// Error is handled in the parent handler
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
if (!submitting) {
handleReset();
@ -805,6 +832,21 @@ export function DMSPushModal({
>
Cancel
</Button>
{onReQuotation && (
<Button
variant="outline"
className="border-orange-500 text-orange-600 hover:bg-orange-50"
onClick={handleReQuotation}
disabled={!comments.trim() || submitting}
>
{submitting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
Request Re-quotation
</Button>
)}
<Button
onClick={handleSubmit}
disabled={!comments.trim() || submitting}

View File

@ -670,7 +670,6 @@ export function DealerCompletionDocumentsModal({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-base sm:text-lg">Closed Expenses</h3>
<Badge className="bg-secondary text-secondary-foreground text-xs">Optional</Badge>
</div>
{!isNonGst && (
<div className="text-[10px] text-gray-500 italic mt-0.5">
@ -743,9 +742,9 @@ export function DealerCompletionDocumentsModal({
{!isNonGst && (
<>
<div className="w-20 sm:w-24 flex-shrink-0">
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">HSN Code</Label>
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">HSN/SAC Code</Label>
<Input
placeholder="HSN"
placeholder="HSN/SAC Code"
value={item.hsnCode || ''}
onChange={(e) =>
handleExpenseChange(item.id, 'hsnCode', e.target.value)

View File

@ -902,7 +902,7 @@ export function DealerProposalSubmissionModal({
</div>
</div>
<div className="w-28 sm:w-32">
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">HSN/SAC</Label>
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">HSN/SAC Code</Label>
<Input
value={item.hsnCode || ''}
onChange={(e) => handleCostItemChange(item.id, 'hsnCode', e.target.value)}

View File

@ -105,3 +105,16 @@ export async function verifyDealerLogin(dealerCode: string): Promise<DealerInfo>
}
}
/**
* Search dealer by code from external Royal Enfield API
* @param dealerCode - The code to search for
*/
export async function searchExternalDealerByCode(dealerCode: string): Promise<any | null> {
try {
const res = await apiClient.get(`/dealers-external/search/${dealerCode}`);
return res.data?.data || res.data || null;
} catch (error) {
console.error('[DealerAPI] Error searching external dealer:', error);
return null;
}
}