manager is pickd from the user tam and search user by dispaly name strategy
This commit is contained in:
parent
636dc4a1c5
commit
001d636e6c
81
src/App.tsx
81
src/App.tsx
@ -24,16 +24,9 @@ import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
|
|||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
||||||
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
|
||||||
import { AuthCallback } from '@/pages/Auth/AuthCallback';
|
import { AuthCallback } from '@/pages/Auth/AuthCallback';
|
||||||
import { createClaimRequest } from '@/services/dealerClaimApi';
|
import { createClaimRequest } from '@/services/dealerClaimApi';
|
||||||
|
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
|
||||||
// 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
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
@ -62,6 +55,20 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
const [dynamicRequests, setDynamicRequests] = useState<any[]>([]);
|
const [dynamicRequests, setDynamicRequests] = useState<any[]>([]);
|
||||||
const [selectedRequestId, setSelectedRequestId] = useState<string>('');
|
const [selectedRequestId, setSelectedRequestId] = useState<string>('');
|
||||||
const [selectedRequestTitle, setSelectedRequestTitle] = 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
|
// Retrieve dynamic requests from localStorage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -266,7 +273,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
setApprovalAction(null);
|
setApprovalAction(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClaimManagementSubmit = async (claimData: any) => {
|
const handleClaimManagementSubmit = async (claimData: any, selectedManagerEmail?: string) => {
|
||||||
try {
|
try {
|
||||||
// Prepare payload for API
|
// Prepare payload for API
|
||||||
const payload = {
|
const payload = {
|
||||||
@ -283,12 +290,17 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined,
|
periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined,
|
||||||
periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined,
|
periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined,
|
||||||
estimatedBudget: claimData.estimatedBudget || undefined,
|
estimatedBudget: claimData.estimatedBudget || undefined,
|
||||||
|
selectedManagerEmail: selectedManagerEmail || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Call API to create claim request
|
// Call API to create claim request
|
||||||
const response = await createClaimRequest(payload);
|
const response = await createClaimRequest(payload);
|
||||||
const createdRequest = response.request;
|
const createdRequest = response.request;
|
||||||
|
|
||||||
|
// Close manager modal if open
|
||||||
|
setManagerModalOpen(false);
|
||||||
|
setManagerModalData(null);
|
||||||
|
|
||||||
toast.success('Claim Request Submitted', {
|
toast.success('Claim Request Submitted', {
|
||||||
description: 'Your claim management request has been created successfully.',
|
description: 'Your claim management request has been created successfully.',
|
||||||
});
|
});
|
||||||
@ -301,6 +313,36 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[App] Error creating claim request:', error);
|
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';
|
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create claim request';
|
||||||
toast.error('Failed to Submit Claim Request', {
|
toast.error('Failed to Submit Claim Request', {
|
||||||
description: errorMessage,
|
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 */}
|
{/* Approval Action Modal */}
|
||||||
{approvalAction && (
|
{approvalAction && (
|
||||||
<ApprovalActionModal
|
<ApprovalActionModal
|
||||||
|
|||||||
164
src/components/modals/ManagerSelectionModal.tsx
Normal file
164
src/components/modals/ManagerSelectionModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -151,23 +151,55 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
(level.levelNumber || level.level_number) === 3
|
(level.levelNumber || level.level_number) === 3
|
||||||
);
|
);
|
||||||
const deptLeadUserId = step3Level?.approverId || step3Level?.approver?.userId;
|
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)
|
// 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) &&
|
const showIOTab = isClaimManagementRequest(apiRequest) &&
|
||||||
(isUserInitiator || isDeptLead);
|
(isUserInitiator || isDeptLead || isStep3CurrentApprover);
|
||||||
|
|
||||||
// Debug logging for troubleshooting
|
// Debug logging for troubleshooting
|
||||||
console.debug('[RequestDetail] IO Tab visibility:', {
|
console.debug('[RequestDetail] IO Tab visibility:', {
|
||||||
isClaimManagement: isClaimManagementRequest(apiRequest),
|
isClaimManagement: isClaimManagementRequest(apiRequest),
|
||||||
isUserInitiator,
|
isUserInitiator,
|
||||||
isDeptLead,
|
isDeptLead,
|
||||||
|
isStep3CurrentApprover,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
currentUserEmail,
|
currentUserEmail,
|
||||||
initiatorUserId,
|
initiatorUserId,
|
||||||
initiatorEmail,
|
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,
|
deptLeadUserId,
|
||||||
|
deptLeadEmail,
|
||||||
|
emailMatch: deptLeadEmail && currentUserEmail ? deptLeadEmail === currentUserEmail : false,
|
||||||
showIOTab,
|
showIOTab,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,26 @@ interface ProposalDetailsCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProposalDetailsCard({ proposalDetails, className }: 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) => {
|
const formatCurrency = (amount?: number | null) => {
|
||||||
if (amount === undefined || amount === null || Number.isNaN(amount)) {
|
if (amount === undefined || amount === null || Number.isNaN(amount)) {
|
||||||
return '₹0.00';
|
return '₹0.00';
|
||||||
@ -99,7 +119,7 @@ export function ProposalDetailsCard({ proposalDetails, className }: ProposalDeta
|
|||||||
Estimated Budget (Total)
|
Estimated Budget (Total)
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-green-700 text-right">
|
<td className="px-4 py-3 text-sm text-green-700 text-right">
|
||||||
{formatCurrency(proposalDetails.estimatedBudgetTotal)}
|
{formatCurrency(estimatedTotal)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -20,6 +20,7 @@ import { InitiatorProposalApprovalModal } from '../modals/InitiatorProposalAppro
|
|||||||
import { DeptLeadIOApprovalModal } from '../modals/DeptLeadIOApprovalModal';
|
import { DeptLeadIOApprovalModal } from '../modals/DeptLeadIOApprovalModal';
|
||||||
import { DealerCompletionDocumentsModal } from '../modals/DealerCompletionDocumentsModal';
|
import { DealerCompletionDocumentsModal } from '../modals/DealerCompletionDocumentsModal';
|
||||||
import { CreditNoteSAPModal } from '../modals/CreditNoteSAPModal';
|
import { CreditNoteSAPModal } from '../modals/CreditNoteSAPModal';
|
||||||
|
import { EmailNotificationTemplateModal } from '../modals/EmailNotificationTemplateModal';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { submitProposal, updateIODetails, submitCompletion, updateEInvoice } from '@/services/dealerClaimApi';
|
import { submitProposal, updateIODetails, submitCompletion, updateEInvoice } from '@/services/dealerClaimApi';
|
||||||
import { getWorkflowDetails, approveLevel, rejectLevel } from '@/services/workflowApi';
|
import { getWorkflowDetails, approveLevel, rejectLevel } from '@/services/workflowApi';
|
||||||
@ -161,6 +162,8 @@ export function DealerClaimWorkflowTab({
|
|||||||
const [showIOApprovalModal, setShowIOApprovalModal] = useState(false);
|
const [showIOApprovalModal, setShowIOApprovalModal] = useState(false);
|
||||||
const [showCompletionModal, setShowCompletionModal] = useState(false);
|
const [showCompletionModal, setShowCompletionModal] = useState(false);
|
||||||
const [showCreditNoteModal, setShowCreditNoteModal] = 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
|
// Load approval flows from real API
|
||||||
const [approvalFlow, setApprovalFlow] = useState<any[]>([]);
|
const [approvalFlow, setApprovalFlow] = useState<any[]>([]);
|
||||||
@ -895,14 +898,17 @@ export function DealerClaimWorkflowTab({
|
|||||||
<Badge className={getStepBadgeVariant(step.status)}>
|
<Badge className={getStepBadgeVariant(step.status)}>
|
||||||
{step.status.toLowerCase()}
|
{step.status.toLowerCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/* Email Template Button (Step 4) */}
|
{/* Email Template Button (Step 4) - Show when approved */}
|
||||||
{step.step === 4 && step.emailTemplateUrl && (
|
{step.step === 4 && step.status === 'approved' && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0 hover:bg-blue-100"
|
className="h-6 w-6 p-0 hover:bg-blue-100"
|
||||||
title="View email template"
|
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" />
|
<Mail className="w-3.5 h-3.5 text-blue-600" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -1235,6 +1241,19 @@ export function DealerClaimWorkflowTab({
|
|||||||
requestId={request?.requestId || request?.id}
|
requestId={request?.requestId || request?.id}
|
||||||
dueDate={request?.dueDate}
|
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"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,9 +13,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { toast } from 'sonner';
|
||||||
import { updateIODetails, getClaimDetails } from '@/services/dealerClaimApi';
|
import { validateIO, updateIODetails, getClaimDetails } from '@/services/dealerClaimApi';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
interface IOTabProps {
|
interface IOTabProps {
|
||||||
request: any;
|
request: any;
|
||||||
@ -26,13 +28,16 @@ interface IOTabProps {
|
|||||||
interface IOBlockedDetails {
|
interface IOBlockedDetails {
|
||||||
ioNumber: string;
|
ioNumber: string;
|
||||||
blockedAmount: number;
|
blockedAmount: number;
|
||||||
availableBalance: number;
|
availableBalance: number; // Available amount before block
|
||||||
|
remainingBalance: number; // Remaining amount after block
|
||||||
blockedDate: string;
|
blockedDate: string;
|
||||||
|
blockedBy: string; // User who blocked
|
||||||
sapDocumentNumber: string;
|
sapDocumentNumber: string;
|
||||||
status: 'blocked' | 'released' | 'failed';
|
status: 'blocked' | 'released' | 'failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
const requestId = apiRequest?.requestId || request?.requestId;
|
const requestId = apiRequest?.requestId || request?.requestId;
|
||||||
|
|
||||||
// Load existing IO data from apiRequest or request
|
// 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 existingAvailableBalance = internalOrder?.ioAvailableBalance || internalOrder?.io_available_balance || 0;
|
||||||
const existingRemainingBalance = internalOrder?.ioRemainingBalance || internalOrder?.io_remaining_balance || 0;
|
const existingRemainingBalance = internalOrder?.ioRemainingBalance || internalOrder?.io_remaining_balance || 0;
|
||||||
const sapDocNumber = internalOrder?.sapDocumentNumber || internalOrder?.sap_document_number || '';
|
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 [ioNumber, setIoNumber] = useState(existingIONumber);
|
||||||
const [fetchingAmount, setFetchingAmount] = useState(false);
|
const [fetchingAmount, setFetchingAmount] = useState(false);
|
||||||
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
|
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
|
||||||
|
const [amountToBlock, setAmountToBlock] = useState<string>('');
|
||||||
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
|
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
|
||||||
const [blockingBudget, setBlockingBudget] = useState(false);
|
const [blockingBudget, setBlockingBudget] = useState(false);
|
||||||
|
|
||||||
// Load existing IO block details from apiRequest
|
// Load existing IO block details from apiRequest
|
||||||
useEffect(() => {
|
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({
|
setBlockedDetails({
|
||||||
ioNumber: existingIONumber,
|
ioNumber: existingIONumber,
|
||||||
blockedAmount: Number(existingBlockedAmount) || 0,
|
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(),
|
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
|
||||||
|
blockedBy: blockedByName,
|
||||||
sapDocumentNumber: sapDocNumber,
|
sapDocumentNumber: sapDocNumber,
|
||||||
status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
|
status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
|
||||||
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
|
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
|
||||||
@ -64,16 +84,16 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
setIoNumber(existingIONumber);
|
setIoNumber(existingIONumber);
|
||||||
|
|
||||||
// Set fetched amount if available balance exists
|
// Set fetched amount if available balance exists
|
||||||
if (existingAvailableBalance > 0) {
|
if (availableBeforeBlock > 0) {
|
||||||
setFetchedAmount(Number(existingAvailableBalance));
|
setFetchedAmount(availableBeforeBlock);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber]);
|
}, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch available budget from SAP
|
* Fetch available budget from SAP
|
||||||
* Validates IO number and gets available balance
|
* Validates IO number and gets available balance (returns dummy data for now)
|
||||||
* Calls updateIODetails with blockedAmount=0 to validate without blocking
|
* Does not store anything in database - only validates
|
||||||
*/
|
*/
|
||||||
const handleFetchAmount = async () => {
|
const handleFetchAmount = async () => {
|
||||||
if (!ioNumber.trim()) {
|
if (!ioNumber.trim()) {
|
||||||
@ -88,32 +108,18 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
|
|
||||||
setFetchingAmount(true);
|
setFetchingAmount(true);
|
||||||
try {
|
try {
|
||||||
// Validate IO number by calling updateIODetails with blockedAmount=0
|
// Call validate IO endpoint - returns dummy data for now, will integrate with SAP later
|
||||||
// This validates the IO with SAP and returns available balance without blocking
|
const ioData = await validateIO(requestId, ioNumber.trim());
|
||||||
// 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
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch updated claim details to get the validated IO data
|
if (ioData.isValid && ioData.availableBalance > 0) {
|
||||||
const claimData = await getClaimDetails(requestId);
|
setFetchedAmount(ioData.availableBalance);
|
||||||
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
|
// Pre-fill amount to block with available balance
|
||||||
|
setAmountToBlock(String(ioData.availableBalance));
|
||||||
if (updatedInternalOrder) {
|
toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
|
||||||
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 {
|
} else {
|
||||||
toast.error('No available balance found for this IO number');
|
toast.error('Invalid IO number or no available balance found');
|
||||||
setFetchedAmount(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error('Failed to fetch IO details after validation');
|
|
||||||
setFetchedAmount(null);
|
setFetchedAmount(null);
|
||||||
|
setAmountToBlock('');
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to fetch IO budget:', error);
|
console.error('Failed to fetch IO budget:', error);
|
||||||
@ -139,27 +145,27 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get claim amount from budget tracking or proposal details
|
const blockAmount = parseFloat(amountToBlock);
|
||||||
const claimAmount = apiRequest?.budgetTracking?.proposalEstimatedBudget ||
|
|
||||||
apiRequest?.proposalDetails?.totalEstimatedBudget ||
|
|
||||||
apiRequest?.claimDetails?.estimatedBudget ||
|
|
||||||
request?.claimAmount ||
|
|
||||||
request?.amount ||
|
|
||||||
0;
|
|
||||||
|
|
||||||
if (claimAmount > fetchedAmount) {
|
if (!amountToBlock || isNaN(blockAmount) || blockAmount <= 0) {
|
||||||
toast.error('Claim amount exceeds available IO budget');
|
toast.error('Please enter a valid amount to block');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockAmount > fetchedAmount) {
|
||||||
|
toast.error('Amount to block exceeds available IO budget');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBlockingBudget(true);
|
setBlockingBudget(true);
|
||||||
try {
|
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, {
|
await updateIODetails(requestId, {
|
||||||
ioNumber: ioNumber.trim(),
|
ioNumber: ioNumber.trim(),
|
||||||
ioAvailableBalance: fetchedAmount,
|
ioAvailableBalance: fetchedAmount,
|
||||||
ioBlockedAmount: claimAmount,
|
ioBlockedAmount: blockAmount,
|
||||||
ioRemainingBalance: fetchedAmount - claimAmount,
|
ioRemainingBalance: fetchedAmount - blockAmount,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch updated claim details to get the blocked IO data
|
// 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;
|
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
|
||||||
|
|
||||||
if (updatedInternalOrder) {
|
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 = {
|
const blocked: IOBlockedDetails = {
|
||||||
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
|
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
|
||||||
blockedAmount: Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || claimAmount),
|
blockedAmount: Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount),
|
||||||
availableBalance: Number(updatedInternalOrder.ioAvailableBalance || updatedInternalOrder.io_available_balance || fetchedAmount),
|
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(),
|
blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(),
|
||||||
|
blockedBy: blockedByName,
|
||||||
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
|
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
|
||||||
status: 'blocked',
|
status: 'blocked',
|
||||||
};
|
};
|
||||||
|
|
||||||
setBlockedDetails(blocked);
|
setBlockedDetails(blocked);
|
||||||
|
setAmountToBlock(''); // Clear the input
|
||||||
toast.success('IO budget blocked successfully in SAP');
|
toast.success('IO budget blocked successfully in SAP');
|
||||||
|
|
||||||
// Refresh request details
|
// 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 (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* IO Budget Management Card */}
|
{/* IO Budget Management Card */}
|
||||||
@ -283,49 +294,51 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
|
|
||||||
{/* Fetched Amount Display */}
|
{/* Fetched Amount Display */}
|
||||||
{fetchedAmount !== null && !blockedDetails && (
|
{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 className="bg-green-50 border-2 border-green-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-green-600 font-medium">Available Budget</p>
|
<p className="text-xs text-gray-600 uppercase tracking-wide mb-1">Available Amount</p>
|
||||||
<p className="text-2xl font-bold text-green-900">
|
<p className="text-2xl font-bold text-green-700">
|
||||||
₹{fetchedAmount.toLocaleString('en-IN')}
|
₹{fetchedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<CircleCheckBig className="w-8 h-8 text-green-600" />
|
<CircleCheckBig className="w-8 h-8 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 pt-3 border-t border-green-200">
|
||||||
<div className="border-t border-green-200 pt-3 space-y-2">
|
<p className="text-xs text-gray-600"><strong>IO Number:</strong> {ioNumber}</p>
|
||||||
<div className="flex justify-between text-sm">
|
<p className="text-xs text-gray-600 mt-1"><strong>Fetched from:</strong> SAP System</p>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{claimAmount > fetchedAmount ? (
|
{/* Amount to Block Input */}
|
||||||
<div className="flex items-center gap-2 bg-red-100 text-red-700 p-3 rounded-md">
|
<div className="space-y-3">
|
||||||
<AlertCircle className="w-4 h-4" />
|
<Label htmlFor="blockAmount">Amount to Block *</Label>
|
||||||
<p className="text-xs font-medium">
|
<div className="relative">
|
||||||
Insufficient budget! Claim amount exceeds available balance.
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">₹</span>
|
||||||
</p>
|
<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>
|
||||||
) : (
|
</div>
|
||||||
|
|
||||||
|
{/* Block Button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleBlockBudget}
|
onClick={handleBlockBudget}
|
||||||
disabled={blockingBudget}
|
disabled={blockingBudget || !amountToBlock || parseFloat(amountToBlock) <= 0 || parseFloat(amountToBlock) > fetchedAmount}
|
||||||
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
|
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
|
||||||
>
|
>
|
||||||
{blockingBudget ? 'Blocking in SAP...' : 'Block Budget in SAP'}
|
<Target className="w-4 h-4 mr-2" />
|
||||||
|
{blockingBudget ? 'Blocking in SAP...' : 'Block IO in SAP'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -345,64 +358,69 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
{blockedDetails ? (
|
{blockedDetails ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Success Banner */}
|
{/* 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="bg-green-50 border-2 border-green-500 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="w-10 h-10 rounded-full bg-emerald-500 flex items-center justify-center">
|
<CircleCheckBig className="w-6 h-6 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
<CircleCheckBig className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-emerald-900">Budget Blocked Successfully!</p>
|
<p className="font-semibold text-green-900">IO Blocked Successfully</p>
|
||||||
<p className="text-xs text-emerald-700">SAP integration completed</p>
|
<p className="text-sm text-green-700 mt-1">Budget has been reserved in SAP system</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Blocked Details */}
|
{/* Blocked Details */}
|
||||||
<div className="space-y-3">
|
<div className="border rounded-lg divide-y">
|
||||||
<div className="flex justify-between py-2 border-b">
|
<div className="p-4">
|
||||||
<span className="text-sm text-gray-600">IO Number:</span>
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Number</p>
|
||||||
<span className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</span>
|
<p className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between py-2 border-b">
|
<div className="p-4">
|
||||||
<span className="text-sm text-gray-600">Blocked Amount:</span>
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">SAP Document Number</p>
|
||||||
<span className="text-sm font-semibold text-green-700">
|
<p className="text-sm font-semibold text-gray-900">{blockedDetails.sapDocumentNumber || 'N/A'}</p>
|
||||||
₹{blockedDetails.blockedAmount.toLocaleString('en-IN')}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between py-2 border-b">
|
<div className="p-4 bg-green-50">
|
||||||
<span className="text-sm text-gray-600">Available Balance:</span>
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p>
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
<p className="text-xl font-bold text-green-700">
|
||||||
₹{blockedDetails.availableBalance.toLocaleString('en-IN')}
|
₹{blockedDetails.blockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
</span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between py-2 border-b">
|
<div className="p-4">
|
||||||
<span className="text-sm text-gray-600">Blocked Date:</span>
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Available Amount (Before Block)</p>
|
||||||
<span className="text-sm font-medium text-gray-900">
|
<p className="text-sm font-medium text-gray-900">
|
||||||
{new Date(blockedDetails.blockedDate).toLocaleString('en-IN')}
|
₹{blockedDetails.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
</span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between py-2 border-b">
|
<div className="p-4 bg-blue-50">
|
||||||
<span className="text-sm text-gray-600">SAP Document No:</span>
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Remaining Amount (After Block)</p>
|
||||||
<span className="text-sm font-mono font-medium text-blue-700">
|
<p className="text-sm font-bold text-blue-700">
|
||||||
{blockedDetails.sapDocumentNumber}
|
₹{blockedDetails.remainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
</span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between py-2">
|
<div className="p-4">
|
||||||
<span className="text-sm text-gray-600">Status:</span>
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked By</p>
|
||||||
<span className="text-xs font-semibold px-2 py-1 bg-green-100 text-green-800 rounded-full">
|
<p className="text-sm font-medium text-gray-900">{blockedDetails.blockedBy}</p>
|
||||||
{blockedDetails.status.toUpperCase()}
|
</div>
|
||||||
</span>
|
<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>
|
||||||
|
|
||||||
{/* 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>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export interface CreateClaimRequestPayload {
|
|||||||
periodStartDate?: string; // ISO date string
|
periodStartDate?: string; // ISO date string
|
||||||
periodEndDate?: string; // ISO date string
|
periodEndDate?: string; // ISO date string
|
||||||
estimatedBudget?: string | number;
|
estimatedBudget?: string | number;
|
||||||
|
selectedManagerEmail?: string; // Optional: When multiple managers found, user selects one
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClaimRequestResponse {
|
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
|
* PUT /api/v1/dealer-claims/:requestId/io
|
||||||
|
* Only stores data when blocking amount > 0
|
||||||
*/
|
*/
|
||||||
export async function updateIODetails(
|
export async function updateIODetails(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
|
|||||||
@ -215,15 +215,17 @@ export function mapToClaimManagementRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map proposal details
|
// Map proposal details
|
||||||
|
const expectedCompletionDate = proposalDetails?.expectedCompletionDate || proposalDetails?.expected_completion_date;
|
||||||
const proposal = proposalDetails ? {
|
const proposal = proposalDetails ? {
|
||||||
proposalDocumentUrl: proposalDetails.proposalDocumentUrl || proposalDetails.proposal_document_url,
|
proposalDocumentUrl: proposalDetails.proposalDocumentUrl || proposalDetails.proposal_document_url,
|
||||||
costBreakup: proposalDetails.costBreakup || proposalDetails.cost_breakup || [],
|
costBreakup: proposalDetails.costBreakup || proposalDetails.cost_breakup || [],
|
||||||
totalEstimatedBudget: proposalDetails.totalEstimatedBudget || proposalDetails.total_estimated_budget || 0,
|
totalEstimatedBudget: proposalDetails.totalEstimatedBudget || proposalDetails.total_estimated_budget || 0,
|
||||||
timelineMode: proposalDetails.timelineMode || proposalDetails.timeline_mode,
|
timelineMode: proposalDetails.timelineMode || proposalDetails.timeline_mode,
|
||||||
expectedCompletionDate: proposalDetails.expectedCompletionDate || proposalDetails.expected_completion_date,
|
expectedCompletionDate: expectedCompletionDate,
|
||||||
expectedCompletionDays: proposalDetails.expectedCompletionDays || proposalDetails.expected_completion_days,
|
expectedCompletionDays: proposalDetails.expectedCompletionDays || proposalDetails.expected_completion_days,
|
||||||
|
timelineForClosure: expectedCompletionDate, // Map expectedCompletionDate to timelineForClosure for ProposalDetailsCard
|
||||||
dealerComments: proposalDetails.dealerComments || proposalDetails.dealer_comments,
|
dealerComments: proposalDetails.dealerComments || proposalDetails.dealer_comments,
|
||||||
submittedAt: proposalDetails.submittedAt || proposalDetails.submitted_at,
|
submittedOn: proposalDetails.submittedAt || proposalDetails.submitted_at || proposalDetails.submittedOn,
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
// Map IO details from dedicated internal_orders table
|
// Map IO details from dedicated internal_orders table
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user