manager is pickd from the user tam and search user by dispaly name strategy

This commit is contained in:
laxmanhalaki 2025-12-12 21:38:01 +05:30
parent 636dc4a1c5
commit 001d636e6c
9 changed files with 650 additions and 157 deletions

View File

@ -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<any[]>([]);
const [selectedRequestId, setSelectedRequestId] = useState<string>('');
const [selectedRequestTitle, setSelectedRequestTitle] = useState<string>('');
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 */}
<ManagerSelectionModal
open={managerModalOpen}
onClose={() => {
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 && (
<ApprovalActionModal

View File

@ -0,0 +1,164 @@
/**
* Manager Selection Modal
* Shows when multiple managers are found or no manager is found
* Allows user to select a manager from the list
*/
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertCircle, CheckCircle2, User, Mail, Building2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
interface Manager {
userId: string;
email: string;
displayName: string;
firstName?: string;
lastName?: string;
department?: string;
}
interface ManagerSelectionModalProps {
open: boolean;
onClose: () => 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 (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{errorType === 'NO_MANAGER_FOUND' ? (
<>
<AlertCircle className="w-5 h-5 text-amber-500" />
Manager Not Found
</>
) : (
<>
<CheckCircle2 className="w-5 h-5 text-blue-500" />
Select Your Manager
</>
)}
</DialogTitle>
<DialogDescription>
{errorType === 'NO_MANAGER_FOUND' ? (
<div className="mt-2">
<p className="text-sm text-gray-600">
{message || 'No reporting manager found in the system. Please ensure your manager is correctly configured in your profile.'}
</p>
<p className="text-sm text-gray-500 mt-2">
Please contact your administrator to update your manager information, or try again later.
</p>
</div>
) : (
<div className="mt-2">
<p className="text-sm text-gray-600">
{message || 'Multiple managers were found with the same name. Please select the correct manager from the list below.'}
</p>
</div>
)}
</DialogDescription>
</DialogHeader>
<div className="mt-4">
{errorType === 'NO_MANAGER_FOUND' ? (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-amber-900">
Unable to Proceed
</p>
<p className="text-sm text-amber-700 mt-1">
We couldn't find your reporting manager in the system. The claim request cannot be created without a valid manager assigned.
</p>
</div>
</div>
</div>
) : (
<div className="space-y-3 max-h-[400px] overflow-y-auto">
{managers.map((manager) => (
<div
key={manager.userId}
className="border rounded-lg p-4 hover:bg-gray-50 transition-colors cursor-pointer"
onClick={() => !isLoading && handleSelect(manager.email)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="font-medium text-gray-900">
{manager.displayName || `${manager.firstName || ''} ${manager.lastName || ''}`.trim() || 'Unknown'}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail className="w-4 h-4" />
<span className="truncate">{manager.email}</span>
</div>
{manager.department && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Building2 className="w-4 h-4" />
<span>{manager.department}</span>
</div>
)}
</div>
</div>
</div>
<Button
onClick={(e) => {
e.stopPropagation();
handleSelect(manager.email);
}}
disabled={isLoading}
className="flex-shrink-0"
size="sm"
>
Select
</Button>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex justify-end gap-3 mt-6">
{errorType === 'NO_MANAGER_FOUND' ? (
<Button onClick={onClose} variant="outline">
Close
</Button>
) : (
<>
<Button onClick={onClose} variant="outline" disabled={isLoading}>
Cancel
</Button>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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,
});

View File

@ -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)
</td>
<td className="px-4 py-3 text-sm text-green-700 text-right">
{formatCurrency(proposalDetails.estimatedBudgetTotal)}
{formatCurrency(estimatedTotal)}
</td>
</tr>
</tbody>

View File

@ -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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-2xl max-w-2xl">
<DialogHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<Mail className="w-5 h-5 text-blue-600" />
</div>
<div>
<DialogTitle className="text-lg leading-none font-semibold">
Email Notification Template
</DialogTitle>
<DialogDescription className="text-sm">
Step {stepNumber}: {stepName}
</DialogDescription>
</div>
</div>
</div>
</DialogHeader>
<div className="space-y-4">
{/* Email Header Section */}
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg p-4 border border-blue-200">
<div className="space-y-2">
<div className="flex items-start gap-2">
<User className="w-4 h-4 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs text-gray-600">To:</p>
<p className="text-sm font-medium text-gray-900">{recipientEmail}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Mail className="w-4 h-4 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs text-gray-600">Subject:</p>
<p className="text-sm font-semibold text-gray-900">{finalSubject}</p>
</div>
</div>
</div>
</div>
{/* Email Body Section */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="space-y-4">
{/* Company Header */}
<div className="flex items-center gap-2 pb-3 border-b border-gray-200">
<Building className="w-5 h-5 text-purple-600" />
<span className="font-semibold text-gray-900">Royal Enfield</span>
</div>
{/* Email Content */}
<div className="prose prose-sm max-w-none">
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 leading-relaxed bg-transparent p-0 border-0">
{finalEmailBody}
</pre>
</div>
{/* Footer */}
<div className="pt-3 border-t border-gray-200">
<div className="flex items-center gap-2 text-xs text-gray-500">
<Calendar className="w-3 h-3" />
<span>Automated email Royal Enfield Claims Portal</span>
</div>
</div>
</div>
</div>
{/* Badges */}
<div className="flex items-center gap-2">
<Badge className="bg-blue-50 text-blue-700 border-blue-200">
Step {stepNumber}
</Badge>
<Badge className="bg-purple-50 text-purple-700 border-purple-200">
Auto-triggered
</Badge>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
onClick={onClose}
className="h-9"
>
<X className="w-4 h-4 mr-2" />
Close
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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<any[]>([]);
@ -895,14 +898,17 @@ export function DealerClaimWorkflowTab({
<Badge className={getStepBadgeVariant(step.status)}>
{step.status.toLowerCase()}
</Badge>
{/* Email Template Button (Step 4) */}
{step.step === 4 && step.emailTemplateUrl && (
{/* Email Template Button (Step 4) - Show when approved */}
{step.step === 4 && step.status === 'approved' && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-blue-100"
title="View email template"
onClick={() => window.open(step.emailTemplateUrl, '_blank')}
onClick={() => {
setSelectedStepForEmail({ stepNumber: step.step, stepName: step.title });
setShowEmailTemplateModal(true);
}}
>
<Mail className="w-3.5 h-3.5 text-blue-600" />
</Button>
@ -1235,6 +1241,19 @@ export function DealerClaimWorkflowTab({
requestId={request?.requestId || request?.id}
dueDate={request?.dueDate}
/>
{/* Email Notification Template Modal */}
<EmailNotificationTemplateModal
isOpen={showEmailTemplateModal}
onClose={() => {
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"
/>
</>
);
}

View File

@ -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<number | null>(null);
const [amountToBlock, setAmountToBlock] = useState<string>('');
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(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
});
// Call validate IO endpoint - returns dummy data for now, will integrate with SAP later
const ioData = await validateIO(requestId, ioNumber.trim());
// Fetch updated claim details to get the validated IO data
const claimData = await getClaimDetails(requestId);
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
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 (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* IO Budget Management Card */}
@ -283,49 +294,51 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
{/* Fetched Amount Display */}
{fetchedAmount !== null && !blockedDetails && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-green-600 font-medium">Available Budget</p>
<p className="text-2xl font-bold text-green-900">
{fetchedAmount.toLocaleString('en-IN')}
</p>
<>
<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>
<CircleCheckBig className="w-8 h-8 text-green-600" />
</div>
<div className="border-t border-green-200 pt-3 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-green-700">Claim Amount:</span>
<span className="font-semibold text-green-900">
{claimAmount.toLocaleString('en-IN')}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-green-700">Balance After Block:</span>
<span className="font-semibold text-green-900">
{(fetchedAmount - claimAmount).toLocaleString('en-IN')}
</span>
<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>
{claimAmount > fetchedAmount ? (
<div className="flex items-center gap-2 bg-red-100 text-red-700 p-3 rounded-md">
<AlertCircle className="w-4 h-4" />
<p className="text-xs font-medium">
Insufficient budget! Claim amount exceeds available balance.
</p>
{/* 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>
) : (
<Button
onClick={handleBlockBudget}
disabled={blockingBudget}
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
>
{blockingBudget ? 'Blocking in SAP...' : 'Block Budget in SAP'}
</Button>
)}
</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>
@ -345,64 +358,69 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
{blockedDetails ? (
<div className="space-y-4">
{/* Success Banner */}
<div className="bg-gradient-to-r from-emerald-50 to-green-50 rounded-lg border-2 border-emerald-300 p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-full bg-emerald-500 flex items-center justify-center">
<CircleCheckBig className="w-6 h-6 text-white" />
</div>
<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-emerald-900">Budget Blocked Successfully!</p>
<p className="text-xs text-emerald-700">SAP integration completed</p>
<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="space-y-3">
<div className="flex justify-between py-2 border-b">
<span className="text-sm text-gray-600">IO Number:</span>
<span className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</span>
<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="flex justify-between py-2 border-b">
<span className="text-sm text-gray-600">Blocked Amount:</span>
<span className="text-sm font-semibold text-green-700">
{blockedDetails.blockedAmount.toLocaleString('en-IN')}
</span>
<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="flex justify-between py-2 border-b">
<span className="text-sm text-gray-600">Available Balance:</span>
<span className="text-sm font-semibold text-gray-900">
{blockedDetails.availableBalance.toLocaleString('en-IN')}
</span>
<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="flex justify-between py-2 border-b">
<span className="text-sm text-gray-600">Blocked Date:</span>
<span className="text-sm font-medium text-gray-900">
{new Date(blockedDetails.blockedDate).toLocaleString('en-IN')}
</span>
<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="flex justify-between py-2 border-b">
<span className="text-sm text-gray-600">SAP Document No:</span>
<span className="text-sm font-mono font-medium text-blue-700">
{blockedDetails.sapDocumentNumber}
</span>
<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="flex justify-between py-2">
<span className="text-sm text-gray-600">Status:</span>
<span className="text-xs font-semibold px-2 py-1 bg-green-100 text-green-800 rounded-full">
{blockedDetails.status.toUpperCase()}
</span>
<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>
{/* Release Button */}
<Button
variant="outline"
onClick={handleReleaseBudget}
className="w-full border-red-300 text-red-700 hover:bg-red-50"
>
Release Budget
</Button>
</div>
) : (
<div className="text-center py-12">

View File

@ -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,

View File

@ -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