From ce90fcf9efdf66c0a149d1ea02d69d883bd2ac38 Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Mon, 22 Dec 2025 19:56:10 +0530 Subject: [PATCH] dealer claim steps reduced and the modal popup layout changed and tanflow login added --- src/App.tsx | 2 +- src/contexts/AuthContext.tsx | 116 ++- .../ClaimApproverSelectionStep.tsx | 899 ++++++++++++++++-- .../ClaimManagementWizard.tsx | 251 ++++- .../components/request-detail/IOTab.tsx | 12 +- .../components/request-detail/WorkflowTab.tsx | 193 ++-- .../request-detail/modals/DMSPushModal.css | 37 + .../request-detail/modals/DMSPushModal.tsx | 298 +++--- .../modals/DealerCompletionDocumentsModal.css | 37 + .../modals/DealerCompletionDocumentsModal.tsx | 224 ++--- .../modals/DealerProposalModal.css | 37 + .../modals/DealerProposalSubmissionModal.tsx | 98 +- .../modals/DeptLeadIOApprovalModal.css | 37 + .../modals/DeptLeadIOApprovalModal.tsx | 309 +++--- .../modals/InitiatorProposalApprovalModal.tsx | 125 +-- src/hooks/useModalManager.ts | 4 + src/pages/Auth/Auth.tsx | 101 +- src/pages/Auth/AuthenticatedApp.tsx | 32 +- src/pages/Auth/TanflowCallback.tsx | 301 ++++++ src/services/tanflowAuth.ts | 201 ++++ 20 files changed, 2558 insertions(+), 756 deletions(-) create mode 100644 src/dealer-claim/components/request-detail/modals/DMSPushModal.css create mode 100644 src/dealer-claim/components/request-detail/modals/DealerCompletionDocumentsModal.css create mode 100644 src/dealer-claim/components/request-detail/modals/DealerProposalModal.css create mode 100644 src/dealer-claim/components/request-detail/modals/DeptLeadIOApprovalModal.css create mode 100644 src/pages/Auth/TanflowCallback.tsx create mode 100644 src/services/tanflowAuth.ts diff --git a/src/App.tsx b/src/App.tsx index 3c383d9..7a938d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -567,7 +567,7 @@ function AppRoutes({ onLogout }: AppProps) { return (
- {/* Auth Callback - Must be before other routes */} + {/* Auth Callback - Unified callback for both OKTA and Tanflow */} } diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index bf1c8dd..2f6c931 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -9,6 +9,7 @@ import { createContext, useContext, useEffect, useState, ReactNode, useRef } fro import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react'; import { TokenManager, isTokenExpired } from '../utils/tokenManager'; import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi } from '../services/authApi'; +import { tanflowLogout } from '../services/tanflowAuth'; interface User { userId?: string; @@ -100,18 +101,28 @@ function BackendAuthProvider({ children }: { children: ReactNode }) { // PRIORITY 2: Check if URL has logout parameter (from redirect) const urlParams = new URLSearchParams(window.location.search); - if (urlParams.has('logout') || urlParams.has('okta_logged_out')) { + if (urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out')) { + console.log('🚪 Logout parameter detected in URL, clearing all tokens'); TokenManager.clearAll(); + // Clear auth provider flag and logout-related flags + sessionStorage.removeItem('auth_provider'); + sessionStorage.removeItem('tanflow_auth_state'); + sessionStorage.removeItem('__logout_in_progress__'); + sessionStorage.removeItem('__force_logout__'); + sessionStorage.removeItem('tanflow_logged_out'); localStorage.clear(); - sessionStorage.clear(); + // Don't clear sessionStorage completely - we might need logout flags setIsAuthenticated(false); setUser(null); setIsLoading(false); - // Clean URL but preserve okta_logged_out flag if it exists (for prompt=login) + // Clean URL but preserve logout flags if they exist (for prompt=login) const cleanParams = new URLSearchParams(); if (urlParams.has('okta_logged_out')) { cleanParams.set('okta_logged_out', 'true'); } + if (urlParams.has('tanflow_logged_out')) { + cleanParams.set('tanflow_logged_out', 'true'); + } const newUrl = cleanParams.toString() ? `/?${cleanParams.toString()}` : '/'; window.history.replaceState({}, document.title, newUrl); return; @@ -120,7 +131,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) { // PRIORITY 3: Skip auth check if on callback page - let callback handler process first // This is critical for production mode where we need to exchange code for tokens // before we can verify session with server - if (window.location.pathname === '/login/callback') { + if (window.location.pathname === '/login/callback' || window.location.pathname === '/login/tanflow/callback') { // Don't check auth status here - let the callback handler do its job // The callback handler will set isAuthenticated after successful token exchange return; @@ -208,24 +219,57 @@ function BackendAuthProvider({ children }: { children: ReactNode }) { } const handleCallback = async () => { + const urlParams = new URLSearchParams(window.location.search); + + // Check if this is a logout redirect (from Tanflow post-logout redirect) + // If it has logout parameters but no code, it's a logout redirect, not a login callback + if ((urlParams.has('logout') || urlParams.has('tanflow_logged_out') || urlParams.has('okta_logged_out')) && !urlParams.get('code')) { + // This is a logout redirect, not a login callback + // Redirect to home page - the mount useEffect will handle logout cleanup + console.log('🚪 Logout redirect detected in callback, redirecting to home'); + // Extract the logout flags from current URL + const logoutFlags = new URLSearchParams(); + if (urlParams.has('tanflow_logged_out')) logoutFlags.set('tanflow_logged_out', 'true'); + if (urlParams.has('okta_logged_out')) logoutFlags.set('okta_logged_out', 'true'); + if (urlParams.has('logout')) logoutFlags.set('logout', urlParams.get('logout') || Date.now().toString()); + const redirectUrl = logoutFlags.toString() ? `/?${logoutFlags.toString()}` : '/?logout=' + Date.now(); + window.location.replace(redirectUrl); + return; + } + // Mark as processed immediately to prevent duplicate calls callbackProcessedRef.current = true; - const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get('code'); const errorParam = urlParams.get('error'); // Clean URL immediately to prevent re-running on re-renders window.history.replaceState({}, document.title, '/login/callback'); + // Detect provider from sessionStorage + const authProvider = sessionStorage.getItem('auth_provider'); + + // If Tanflow provider, handle it separately (will be handled by TanflowCallback component) + if (authProvider === 'tanflow') { + // Clear the provider flag and let TanflowCallback handle it + // Reset ref so TanflowCallback can process + callbackProcessedRef.current = false; + return; + } + + // Handle OKTA callback (default) if (errorParam) { setError(new Error(`Authentication error: ${errorParam}`)); setIsLoading(false); + // Clear provider flag + sessionStorage.removeItem('auth_provider'); return; } if (!code) { setIsLoading(false); + // Clear provider flag + sessionStorage.removeItem('auth_provider'); return; } @@ -245,6 +289,9 @@ function BackendAuthProvider({ children }: { children: ReactNode }) { setIsAuthenticated(true); setError(null); + // Clear provider flag after successful authentication + sessionStorage.removeItem('auth_provider'); + // Clean URL after success window.history.replaceState({}, document.title, '/'); } catch (err: any) { @@ -252,6 +299,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) { setError(err); setIsAuthenticated(false); setUser(null); + // Clear provider flag on error + sessionStorage.removeItem('auth_provider'); // Reset ref on error so user can retry if needed callbackProcessedRef.current = false; } finally { @@ -412,9 +461,12 @@ function BackendAuthProvider({ children }: { children: ReactNode }) { const scope = 'openid profile email'; const state = Math.random().toString(36).substring(7); + // Store provider type to identify OKTA callback + sessionStorage.setItem('auth_provider', 'okta'); + // Check if we're coming from a logout - if so, add prompt=login to force re-authentication const urlParams = new URLSearchParams(window.location.search); - const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out'); + const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out'); let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` + `client_id=${clientId}&` + @@ -439,9 +491,14 @@ function BackendAuthProvider({ children }: { children: ReactNode }) { const logout = async () => { try { // CRITICAL: Get id_token from TokenManager before clearing anything - // Okta logout endpoint works better with id_token_hint to properly end the session - // Note: Currently not used but kept for future Okta integration - void TokenManager.getIdToken(); + // Needed for both Okta and Tanflow logout endpoints + const idToken = TokenManager.getIdToken(); + + // Detect which provider was used for login (check sessionStorage or user data) + // If auth_provider is set, use it; otherwise check if we have Tanflow id_token pattern + const authProvider = sessionStorage.getItem('auth_provider') || + (idToken && idToken.includes('tanflow') ? 'tanflow' : null) || + 'okta'; // Default to OKTA if unknown // Set logout flag to prevent auto-authentication after redirect // This must be set BEFORE clearing storage so it survives @@ -459,29 +516,58 @@ function BackendAuthProvider({ children }: { children: ReactNode }) { // IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies try { await logoutApi(); + console.log('🚪 Backend logout API called successfully'); } catch (err) { console.error('🚪 Logout API error:', err); console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared'); // Continue with logout even if API call fails } - // Clear all authentication data EXCEPT the logout flags and id_token (we need it for Okta logout) - - // Clear tokens but preserve logout flags + // Preserve logout flags, id_token, and auth_provider BEFORE clearing tokens const logoutInProgress = sessionStorage.getItem('__logout_in_progress__'); const forceLogout = sessionStorage.getItem('__force_logout__'); + const storedAuthProvider = sessionStorage.getItem('auth_provider'); - // Use TokenManager.clearAll() but then restore logout flags + // Clear all tokens EXCEPT id_token (we need it for provider logout) + // Note: We'll clear id_token after provider logout + // Clear tokens (but we'll restore id_token if needed) TokenManager.clearAll(); - // Restore logout flags immediately after clearAll + // Restore logout flags and id_token immediately after clearAll if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress); if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout); + if (idToken) { + TokenManager.setIdToken(idToken); // Restore id_token for provider logout + } + if (storedAuthProvider) { + sessionStorage.setItem('auth_provider', storedAuthProvider); // Restore for logout + } // Small delay to ensure sessionStorage is written before redirect await new Promise(resolve => setTimeout(resolve, 100)); - // Redirect directly to login page with flags + // Handle provider-specific logout + if (authProvider === 'tanflow' && idToken) { + console.log('🚪 Initiating Tanflow logout...'); + // Tanflow logout - redirect to Tanflow logout endpoint + // This will clear Tanflow session and redirect back to our app + try { + tanflowLogout(idToken); + // tanflowLogout will redirect, so we don't need to do anything else here + return; + } catch (tanflowLogoutError) { + console.error('🚪 Tanflow logout error:', tanflowLogoutError); + // Fall through to default logout flow + } + } + + // OKTA logout or fallback: Clear auth_provider and redirect to login page with flags + console.log('🚪 Using OKTA logout flow or fallback'); + sessionStorage.removeItem('auth_provider'); + // Clear id_token now since we're not using provider logout + if (idToken) { + TokenManager.clearAll(); // Clear id_token too + } // The okta_logged_out flag will trigger prompt=login in the login() function // This forces re-authentication even if Okta session still exists const loginUrl = `${window.location.origin}/?okta_logged_out=true&logout=${Date.now()}`; diff --git a/src/dealer-claim/components/request-creation/ClaimApproverSelectionStep.tsx b/src/dealer-claim/components/request-creation/ClaimApproverSelectionStep.tsx index 3fc16f4..e98e7e6 100644 --- a/src/dealer-claim/components/request-creation/ClaimApproverSelectionStep.tsx +++ b/src/dealer-claim/components/request-creation/ClaimApproverSelectionStep.tsx @@ -1,31 +1,32 @@ /** * ClaimApproverSelectionStep Component - * Step 2: Manual approver selection for all 8 steps in dealer claim workflow - * Similar to ApprovalWorkflowStep but fixed to 8 steps with predefined step names + * Step 2: Manual approver selection for all 5 steps in dealer claim workflow + * Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are now activity logs only, not approval steps + * Similar to ApprovalWorkflowStep but fixed to 5 steps with predefined step names */ import { motion } from 'framer-motion'; -import { useEffect } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Badge } from '@/components/ui/badge'; -import { Users, Shield, CheckCircle, Info, Clock, User } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { Users, Shield, CheckCircle, Info, Clock, User, Plus, X, AtSign } from 'lucide-react'; import { useMultiUserSearch } from '@/hooks/useUserSearch'; -import { ensureUserExists } from '@/services/userApi'; +import { ensureUserExists, searchUsers, type UserSummary } from '@/services/userApi'; import { toast } from 'sonner'; -// Fixed 8-step workflow for dealer claims +// Fixed 5-step workflow for dealer claims +// Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are now activity logs only, not approval steps const CLAIM_STEPS = [ { level: 1, name: 'Dealer Proposal Submission', description: 'Dealer submits proposal documents', defaultTat: 72, isAuto: false, approverType: 'dealer' }, { level: 2, name: 'Requestor Evaluation', description: 'Initiator evaluates dealer proposal', defaultTat: 48, isAuto: false, approverType: 'initiator' }, { level: 3, name: 'Department Lead Approval', description: 'Department lead approves and blocks IO budget', defaultTat: 72, isAuto: false, approverType: 'manual' }, - { level: 4, name: 'Activity Creation', description: 'System auto-processes activity creation', defaultTat: 1, isAuto: true, approverType: 'system' }, - { level: 5, name: 'Dealer Completion Documents', description: 'Dealer submits completion documents', defaultTat: 120, isAuto: false, approverType: 'dealer' }, - { level: 6, name: 'Requestor Claim Approval', description: 'Initiator approves completion', defaultTat: 48, isAuto: false, approverType: 'initiator' }, - { level: 7, name: 'E-Invoice Generation', description: 'System generates e-invoice via DMS', defaultTat: 1, isAuto: true, approverType: 'system' }, - { level: 8, name: 'Credit Note Confirmation', description: 'System/Finance processes credit note confirmation', defaultTat: 48, isAuto: true, approverType: 'system' }, + { level: 4, name: 'Dealer Completion Documents', description: 'Dealer submits completion documents', defaultTat: 120, isAuto: false, approverType: 'dealer' }, + { level: 5, name: 'Requestor Claim Approval', description: 'Initiator approves completion', defaultTat: 48, isAuto: false, approverType: 'initiator' }, ]; interface ClaimApprover { @@ -35,6 +36,10 @@ interface ClaimApprover { level: number; tat?: number | string; tatType?: 'hours' | 'days'; + isAdditional?: boolean; // Flag to identify additional approvers added between steps + insertAfterLevel?: number; // Original level after which this was inserted + stepName?: string; // Step name/title for additional approvers + originalStepLevel?: number; // Original step level for fixed steps (to track which step this approver belongs to) } interface ClaimApproverSelectionStepProps { @@ -61,6 +66,17 @@ export function ClaimApproverSelectionStep({ onValidate, }: ClaimApproverSelectionStepProps) { const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch(); + + // State for add approver modal + const [showAddApproverModal, setShowAddApproverModal] = useState(false); + const [addApproverEmail, setAddApproverEmail] = useState(''); + const [addApproverTat, setAddApproverTat] = useState(24); + const [addApproverTatType, setAddApproverTatType] = useState<'hours' | 'days'>('hours'); + const [addApproverInsertAfter, setAddApproverInsertAfter] = useState(3); + const [addApproverSearchResults, setAddApproverSearchResults] = useState([]); + const [isSearchingApprover, setIsSearchingApprover] = useState(false); + const [selectedAddApproverUser, setSelectedAddApproverUser] = useState(null); + const addApproverSearchTimer = useRef(null); // Validation function to check for missing approvers const validateApprovers = (): { isValid: boolean; missingSteps: string[] } => { @@ -78,7 +94,7 @@ export function ClaimApproverSelectionStep({ const approver = approvers.find((a: ClaimApprover) => a.level === step.level); if (!approver || !approver.email || !approver.userId || !approver.tat) { - missingSteps.push(`Step ${step.level}: ${step.name}`); + missingSteps.push(`${step.name}`); } }); @@ -100,63 +116,135 @@ export function ClaimApproverSelectionStep({ // Initialize approvers array for all 8 steps useEffect(() => { const currentApprovers = formData.approvers || []; - const newApprovers: ClaimApprover[] = []; - - CLAIM_STEPS.forEach((step) => { - const existingApprover = currentApprovers.find((a: ClaimApprover) => a.level === step.level); + + // If we already have approvers (including additional ones), don't reinitialize + // This prevents creating duplicates when approvers have been shifted + if (currentApprovers.length > 0) { + // Just ensure all fixed steps have their approvers, but don't recreate shifted ones + const newApprovers: ClaimApprover[] = []; + const additionalApprovers = currentApprovers.filter((a: ClaimApprover) => a.isAdditional); - if (step.isAuto) { - // System steps - no approver needed - // Step 8 is System/Finance, use finance email - const systemEmail = step.level === 8 ? 'finance@royalenfield.com' : 'system@royalenfield.com'; - const systemName = step.level === 8 ? 'System/Finance' : 'System'; - newApprovers.push({ - email: systemEmail, - name: systemName, - level: step.level, - tat: step.defaultTat, - tatType: 'hours', - }); - } else if (step.approverType === 'dealer') { - // Dealer steps - use dealer email - newApprovers.push({ - email: formData.dealerEmail || '', - name: formData.dealerName || '', - level: step.level, - tat: step.defaultTat, - tatType: 'hours', - }); - } else if (step.approverType === 'initiator') { - // Initiator steps - use current user - newApprovers.push({ - email: currentUserEmail || '', - name: currentUserName || currentUserEmail || 'User', - userId: currentUserId, - level: step.level, - tat: step.defaultTat, - tatType: 'hours', - }); - } else { - // Manual steps - use existing or create empty - newApprovers.push(existingApprover || { - email: '', - name: '', - level: step.level, - tat: step.defaultTat, - tatType: 'hours', - }); + CLAIM_STEPS.forEach((step) => { + // Find existing approver by originalStepLevel (handles shifted levels) + const existing = currentApprovers.find((a: ClaimApprover) => + a.originalStepLevel === step.level || (!a.originalStepLevel && !a.isAdditional && a.level === step.level) + ); + + if (existing) { + // Use existing approver (preserves shifted level) + newApprovers.push(existing); + } else { + // Create new approver only if it doesn't exist + if (step.isAuto) { + // System steps + const systemEmail = step.level === 8 ? 'finance@royalenfield.com' : 'system@royalenfield.com'; + const systemName = step.level === 8 ? 'System/Finance' : 'System'; + newApprovers.push({ + email: systemEmail, + name: systemName, + level: step.level, + tat: step.defaultTat, + tatType: 'hours', + originalStepLevel: step.level, + }); + } else if (step.approverType === 'dealer') { + newApprovers.push({ + email: formData.dealerEmail || '', + name: formData.dealerName || '', + level: step.level, + tat: step.defaultTat, + tatType: 'hours', + originalStepLevel: step.level, + }); + } else if (step.approverType === 'initiator') { + newApprovers.push({ + email: currentUserEmail || '', + name: currentUserName || currentUserEmail || 'User', + userId: currentUserId, + level: step.level, + tat: step.defaultTat, + tatType: 'hours', + originalStepLevel: step.level, + }); + } else { + newApprovers.push({ + email: '', + name: '', + level: step.level, + tat: step.defaultTat, + tatType: 'hours', + originalStepLevel: step.level, + }); + } + } + }); + + // Add back all additional approvers + additionalApprovers.forEach((addApprover: ClaimApprover) => { + newApprovers.push(addApprover); + }); + + // Sort by level + newApprovers.sort((a, b) => a.level - b.level); + + // Only update if there are actual changes (to avoid infinite loops) + const hasChanges = JSON.stringify(currentApprovers.map(a => ({ level: a.level, originalStepLevel: a.originalStepLevel }))) !== + JSON.stringify(newApprovers.map(a => ({ level: a.level, originalStepLevel: a.originalStepLevel }))); + + if (hasChanges) { + updateFormData('approvers', newApprovers); } - }); + } else { + // Initial setup - create approvers for all 8 steps + const newApprovers: ClaimApprover[] = []; + + CLAIM_STEPS.forEach((step) => { + // System steps (Activity Creation, E-Invoice Generation, Credit Note Confirmation) are no longer approval steps + // They are handled as activity logs only, so skip them + if (step.isAuto) { + // Skip system steps - they are now activity logs only + return; + } else if (step.approverType === 'dealer') { + newApprovers.push({ + email: formData.dealerEmail || '', + name: formData.dealerName || '', + level: step.level, + tat: step.defaultTat, + tatType: 'hours', + originalStepLevel: step.level, + }); + } else if (step.approverType === 'initiator') { + newApprovers.push({ + email: currentUserEmail || '', + name: currentUserName || currentUserEmail || 'User', + userId: currentUserId, + level: step.level, + tat: step.defaultTat, + tatType: 'hours', + originalStepLevel: step.level, + }); + } else { + newApprovers.push({ + email: '', + name: '', + level: step.level, + tat: step.defaultTat, + tatType: 'hours', + originalStepLevel: step.level, + }); + } + }); - // Only update if approvers array is empty or structure changed - if (currentApprovers.length === 0 || currentApprovers.length !== newApprovers.length) { updateFormData('approvers', newApprovers); } }, [formData.dealerEmail, formData.dealerName, currentUserEmail, currentUserId, currentUserName]); const handleApproverEmailChange = (level: number, value: string) => { const approvers = [...(formData.approvers || [])]; - const index = approvers.findIndex((a: ClaimApprover) => a.level === level); + // Find by originalStepLevel first, then fallback to level for backwards compatibility + const index = approvers.findIndex((a: ClaimApprover) => + a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional) + ); if (index === -1) { // Create new approver entry @@ -167,6 +255,7 @@ export function ClaimApproverSelectionStep({ level: level, tat: step?.defaultTat || 48, tatType: 'hours', + originalStepLevel: level, // Track original step }); } else { // Update existing approver @@ -249,7 +338,10 @@ export function ClaimApproverSelectionStep({ // Update approver in array const updatedApprovers = [...(formData.approvers || [])]; - const approverIndex = updatedApprovers.findIndex((a: ClaimApprover) => a.level === level); + // Find by originalStepLevel first, then fallback to level for backwards compatibility + const approverIndex = updatedApprovers.findIndex((a: ClaimApprover) => + a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional) + ); if (approverIndex === -1) { const step = CLAIM_STEPS.find(s => s.level === level); @@ -260,6 +352,7 @@ export function ClaimApproverSelectionStep({ level: level, tat: step?.defaultTat || 48, tatType: 'hours' as const, + originalStepLevel: level, // Track original step }); } else { const existingApprover = updatedApprovers[approverIndex]; @@ -269,6 +362,8 @@ export function ClaimApproverSelectionStep({ email: selectedUser.email, name: selectedUser.displayName || [selectedUser.firstName, selectedUser.lastName].filter(Boolean).join(' '), userId: dbUser.userId, + // Preserve originalStepLevel if it exists + originalStepLevel: existingApprover.originalStepLevel || level, }; } } @@ -291,7 +386,10 @@ export function ClaimApproverSelectionStep({ const handleTatChange = (level: number, tat: number | string) => { const approvers = [...(formData.approvers || [])]; - const index = approvers.findIndex((a: ClaimApprover) => a.level === level); + // Find by originalStepLevel first, then fallback to level for backwards compatibility + const index = approvers.findIndex((a: ClaimApprover) => + a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional) + ); if (index !== -1) { const existingApprover = approvers[index]; @@ -307,7 +405,10 @@ export function ClaimApproverSelectionStep({ const handleTatTypeChange = (level: number, tatType: 'hours' | 'days') => { const approvers = [...(formData.approvers || [])]; - const index = approvers.findIndex((a: ClaimApprover) => a.level === level); + // Find by originalStepLevel first, then fallback to level for backwards compatibility + const index = approvers.findIndex((a: ClaimApprover) => + a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional) + ); if (index !== -1) { const existingApprover = approvers[index]; @@ -322,7 +423,283 @@ export function ClaimApproverSelectionStep({ } }; + // Handle adding additional approver between steps + const handleAddApproverEmailChange = (value: string) => { + setAddApproverEmail(value); + + // Clear selectedUser when manually editing + if (selectedAddApproverUser && selectedAddApproverUser.email.toLowerCase() !== value.toLowerCase()) { + setSelectedAddApproverUser(null); + } + + // Clear existing timer + if (addApproverSearchTimer.current) { + clearTimeout(addApproverSearchTimer.current); + } + + // Only trigger search when using @ sign + if (!value || !value.startsWith('@') || value.length < 2) { + setAddApproverSearchResults([]); + setIsSearchingApprover(false); + return; + } + + // Start search with debounce + setIsSearchingApprover(true); + addApproverSearchTimer.current = setTimeout(async () => { + try { + const term = value.slice(1); // Remove @ prefix + const response = await searchUsers(term, 10); + const results = response.data?.data || []; + setAddApproverSearchResults(results); + } catch (error) { + console.error('Search failed:', error); + setAddApproverSearchResults([]); + } finally { + setIsSearchingApprover(false); + } + }, 300); + }; + + const handleSelectAddApproverUser = async (user: UserSummary) => { + try { + await ensureUserExists({ + userId: user.userId, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + department: user.department, + phone: user.phone, + mobilePhone: user.mobilePhone, + designation: user.designation, + jobTitle: user.jobTitle, + manager: user.manager, + employeeId: user.employeeId, + employeeNumber: user.employeeNumber, + secondEmail: user.secondEmail, + location: user.location + }); + + setAddApproverEmail(user.email); + setSelectedAddApproverUser(user); + setAddApproverSearchResults([]); + setIsSearchingApprover(false); + } catch (error) { + console.error('Failed to ensure user exists:', error); + toast.error('Failed to verify user. Please try again.'); + } + }; + + const handleConfirmAddApprover = async () => { + const emailToAdd = addApproverEmail.trim().toLowerCase(); + + if (!emailToAdd) { + toast.error('Please enter an email address'); + return; + } + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(emailToAdd)) { + toast.error('Please enter a valid email address'); + return; + } + + // Validate TAT + const tatNumber = typeof addApproverTat === 'string' ? Number(addApproverTat) : addApproverTat; + if (!tatNumber || tatNumber <= 0 || isNaN(tatNumber)) { + toast.error('Please enter valid TAT (minimum 1)'); + return; + } + + const maxTat = addApproverTatType === 'days' ? 30 : 720; + const tatValue = addApproverTatType === 'days' ? tatNumber * 24 : tatNumber; + if (tatValue > 720) { + toast.error(`TAT cannot exceed ${maxTat} ${addApproverTatType === 'days' ? 'days' : 'hours'}`); + return; + } + + // Validate insert after level - don't allow after "Requestor Claim Approval" + const requestorClaimApprovalStep = CLAIM_STEPS.find(s => s.name === 'Requestor Claim Approval'); + if (requestorClaimApprovalStep && addApproverInsertAfter >= requestorClaimApprovalStep.level) { + toast.error('Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final.'); + return; + } + + // Check if user is trying to add themselves + if (emailToAdd === currentUserEmail?.toLowerCase()) { + toast.error('You cannot add yourself as an additional approver.'); + return; + } + + // Check for duplicates + const approvers = formData.approvers || []; + const isDuplicate = approvers.some( + (a: ClaimApprover) => + (a.userId && selectedAddApproverUser?.userId && a.userId === selectedAddApproverUser.userId) || + a.email?.toLowerCase() === emailToAdd + ); + + if (isDuplicate) { + toast.error('This user is already assigned as an approver.'); + return; + } + + // Find the approver for the selected step by its originalStepLevel + // This handles cases where steps have been shifted due to previous additional approvers + const approverAfter = approvers.find((a: ClaimApprover) => + a.originalStepLevel === addApproverInsertAfter || + (!a.originalStepLevel && !a.isAdditional && a.level === addApproverInsertAfter) + ); + + // Get the current level of the approver we're inserting after + // If the step has been shifted, use its current level; otherwise use the original level + const currentLevelAfter = approverAfter ? approverAfter.level : addApproverInsertAfter; + + // Calculate insert level based on current shifted level + const insertLevel = currentLevelAfter + 1; + + // If user was NOT selected via @ search, validate against Okta + if (!selectedAddApproverUser || selectedAddApproverUser.email.toLowerCase() !== emailToAdd) { + try { + const response = await searchUsers(emailToAdd, 1); + const searchOktaResults = response.data?.data || []; + + if (searchOktaResults.length === 0) { + toast.error('User not found in organization directory. Please use @ to search for users.'); + return; + } + + const foundUser = searchOktaResults[0]; + await ensureUserExists({ + userId: foundUser.userId, + email: foundUser.email, + displayName: foundUser.displayName, + firstName: foundUser.firstName, + lastName: foundUser.lastName, + department: foundUser.department, + phone: foundUser.phone, + mobilePhone: foundUser.mobilePhone, + designation: foundUser.designation, + jobTitle: foundUser.jobTitle, + manager: foundUser.manager, + employeeId: foundUser.employeeId, + employeeNumber: foundUser.employeeNumber, + secondEmail: foundUser.secondEmail, + location: foundUser.location + }); + + // Use found user - insert at integer level and shift subsequent approvers + // insertLevel is already calculated above based on current shifted level + const newApprover: ClaimApprover = { + email: foundUser.email, + name: foundUser.displayName || [foundUser.firstName, foundUser.lastName].filter(Boolean).join(' '), + userId: foundUser.userId, + level: insertLevel, // Use current shifted level + 1 + tat: typeof addApproverTat === 'string' ? Number(addApproverTat) : addApproverTat, + tatType: addApproverTatType, + isAdditional: true, + insertAfterLevel: addApproverInsertAfter, // Store original step level for reference + stepName: `Additional Approver - ${foundUser.displayName || foundUser.email}`, + }; + + // Shift all approvers with level >= insertLevel up by 1 (both fixed and additional) + const updatedApprovers = approvers.map((a: ClaimApprover) => { + if (a.level >= insertLevel) { + return { ...a, level: a.level + 1 }; + } + return a; + }); + + // Insert the new approver + updatedApprovers.push(newApprover); + + // Sort by level to maintain order + updatedApprovers.sort((a, b) => a.level - b.level); + + updateFormData('approvers', updatedApprovers); + toast.success(`Additional approver added and subsequent steps shifted`); + } catch (error) { + console.error('Failed to validate approver:', error); + toast.error('Failed to validate user. Please try again.'); + return; + } + } else { + // User was selected via @ search - insert at integer level and shift subsequent approvers + // insertLevel is already calculated above based on current shifted level + const newApprover: ClaimApprover = { + email: selectedAddApproverUser.email, + name: selectedAddApproverUser.displayName || [selectedAddApproverUser.firstName, selectedAddApproverUser.lastName].filter(Boolean).join(' '), + userId: selectedAddApproverUser.userId, + level: insertLevel, // Use current shifted level + 1 + tat: addApproverTat, + tatType: addApproverTatType, + isAdditional: true, + insertAfterLevel: addApproverInsertAfter, // Store original step level for reference + stepName: `Additional Approver - ${selectedAddApproverUser.displayName || selectedAddApproverUser.email}`, + }; + + // Shift all approvers with level >= insertLevel up by 1 (both fixed and additional) + const updatedApprovers = approvers.map((a: ClaimApprover) => { + if (a.level >= insertLevel) { + return { ...a, level: a.level + 1 }; + } + return a; + }); + + // Insert the new approver + updatedApprovers.push(newApprover); + + // Sort by level to maintain order + updatedApprovers.sort((a, b) => a.level - b.level); + + updateFormData('approvers', updatedApprovers); + toast.success(`Additional approver added and subsequent steps shifted`); + } + + // Reset modal state + setAddApproverEmail(''); + setAddApproverTat(24); + setAddApproverTatType('hours'); + setAddApproverInsertAfter(3); + setSelectedAddApproverUser(null); + setAddApproverSearchResults([]); + setShowAddApproverModal(false); + }; + + const handleRemoveAdditionalApprover = (level: number) => { + const approvers = [...(formData.approvers || [])]; + const approverToRemove = approvers.find((a: ClaimApprover) => a.level === level); + + if (!approverToRemove) return; + + // Remove the additional approver + const filtered = approvers.filter((a: ClaimApprover) => a.level !== level); + + // Shift all approvers with level > removed level down by 1 + const updatedApprovers = filtered.map((a: ClaimApprover) => { + if (a.level > level && !a.isAdditional) { + return { ...a, level: a.level - 1 }; + } + return a; + }); + + // Sort by level to maintain order + updatedApprovers.sort((a, b) => a.level - b.level); + + updateFormData('approvers', updatedApprovers); + toast.success('Additional approver removed and subsequent steps shifted back'); + }; + + // Get all approvers sorted by level (including additional ones) + const getAllApproversSorted = () => { + const approvers = formData.approvers || []; + return [...approvers].sort((a, b) => a.level - b.level); + }; + const approvers = formData.approvers || []; + const sortedApprovers = getAllApproversSorted(); return (

Approver Selection

- Assign approvers for all 8 workflow steps with TAT (Turn Around Time) + Assign approvers for workflow steps with TAT (Turn Around Time)

@@ -350,7 +727,7 @@ export function ClaimApproverSelectionStep({ Workflow Steps Information - Some steps are pre-filled (Dealer, Initiator, System). You need to assign approvers for Step 3 only. Step 8 is handled by System/Finance. + Some steps are pre-filled (Dealer, Initiator, System). You need to assign approvers for "Department Lead Approval" only. Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final. @@ -360,13 +737,25 @@ export function ClaimApproverSelectionStep({ - Approval Hierarchy (8 Steps) + Approval Hierarchy - Define approvers and TAT for each step. Steps 1, 2, 4, 5, 6, 7, 8 are pre-filled. Only Step 3 requires manual assignment. + Define approvers and TAT for each step. Some steps are pre-filled (Dealer, Initiator, System). Only "Department Lead Approval" requires manual assignment. + {/* Add Additional Approver Button */} +
+ +
{/* Initiator Card */}
@@ -384,24 +773,99 @@ export function ClaimApproverSelectionStep({
{/* Dynamic Approver Cards - Filter out system steps (auto-processed) */} - {CLAIM_STEPS.filter((step) => !step.isAuto).map((step, index, filteredSteps) => { - const approver = approvers.find((a: ClaimApprover) => a.level === step.level) || { - email: '', - name: '', - level: step.level, - tat: step.defaultTat, - tatType: 'hours' as const, - }; + {(() => { + // Count additional approvers before first step + const additionalBeforeFirst = sortedApprovers.filter((a: ClaimApprover) => + a.isAdditional && a.insertAfterLevel === 0 + ); + + let displayIndex = additionalBeforeFirst.length; // Start index after additional approvers before first step + + return CLAIM_STEPS.filter((step) => !step.isAuto).map((step, index, filteredSteps) => { + // Find approver by originalStepLevel first, then fallback to level + const approver = approvers.find((a: ClaimApprover) => + a.originalStepLevel === step.level || (!a.originalStepLevel && a.level === step.level && !a.isAdditional) + ) || { + email: '', + name: '', + level: step.level, + tat: step.defaultTat, + tatType: 'hours' as const, + originalStepLevel: step.level, + }; - const isLast = index === filteredSteps.length - 1; - const isPreFilled = step.isAuto || step.approverType === 'dealer' || step.approverType === 'initiator'; - const isEditable = !step.isAuto; + const isLast = index === filteredSteps.length - 1; + const isPreFilled = step.isAuto || step.approverType === 'dealer' || step.approverType === 'initiator'; + const isEditable = !step.isAuto; - return ( -
-
-
-
+ // Find additional approvers that should be shown after this step + // Additional approvers inserted after this step will have insertAfterLevel === step.level + // and their level will be step.level + 1 (or higher if multiple are added) + const additionalApproversAfter = sortedApprovers.filter( + (a: ClaimApprover) => + a.isAdditional && + a.insertAfterLevel === step.level + ).sort((a, b) => a.level - b.level); + + // Calculate current step's display number + const currentStepDisplayNumber = displayIndex + 1; + + // Increment display index for this step + displayIndex++; + + // Increment display index for each additional approver after this step + displayIndex += additionalApproversAfter.length; + + return ( +
+
+
+
+ + {/* Render additional approvers before this step if any */} + {index === 0 && additionalBeforeFirst.map((addApprover: ClaimApprover, addIndex: number) => { + const addDisplayNumber = addIndex + 1; // Number from 1 for first additional approvers + return ( +
+
+
+
+
+
+
+ {addDisplayNumber} +
+
+
+ + Additional Approver + + + ADDITIONAL + + +
+

+ {addApprover.name || addApprover.email} +

+
+
Email: {addApprover.email}
+
TAT: {addApprover.tat} {addApprover.tatType}
+
+
+
+
+
+ ); + })}
- {step.level} + {currentStepDisplayNumber}
@@ -531,9 +995,66 @@ export function ClaimApproverSelectionStep({
+ + {/* Render additional approvers after this step */} + {additionalApproversAfter.map((addApprover: ClaimApprover, addIndex: number) => { + // Additional approvers come after the current step, so they should be numbered after it + const addDisplayNumber = currentStepDisplayNumber + addIndex + 1; + return ( +
+
+
+
+
+
+
+ {addDisplayNumber} +
+
+
+ + {addApprover.stepName || 'Additional Approver'} + + + ADDITIONAL + + {addApprover.email && addApprover.userId && ( + + + Verified + + )} + +
+

+ {addApprover.name || addApprover.email || 'No approver assigned'} +

+ {addApprover.email && ( +
+
Email: {addApprover.email}
+ {addApprover.tat && ( +
TAT: {addApprover.tat} {addApprover.tatType}
+ )} +
+ )} +
+
+
+
+ ); + })}
); - })} + }); + })()} @@ -547,18 +1068,36 @@ export function ClaimApproverSelectionStep({
- {approvers.map((approver: ClaimApprover) => { - const step = CLAIM_STEPS.find(s => s.level === approver.level); - if (!step || step.isAuto) return null; + {sortedApprovers.map((approver: ClaimApprover) => { + // Skip system/auto steps + // Find step by originalStepLevel first, then fallback to level + const step = approver.originalStepLevel + ? CLAIM_STEPS.find(s => s.level === approver.originalStepLevel) + : CLAIM_STEPS.find(s => s.level === approver.level && !approver.isAdditional); + + if (step?.isAuto) return null; const tat = Number(approver.tat || 0); const tatType = approver.tatType || 'hours'; const hours = tatType === 'days' ? tat * 24 : tat; if (!tat) return null; + // Handle additional approvers + if (approver.isAdditional) { + const afterStep = CLAIM_STEPS.find(s => s.level === approver.insertAfterLevel); + return ( +
+ + {approver.stepName || `Additional Approver (after "${afterStep?.name || 'Unknown'}")`} + + {hours} hours +
+ ); + } + return (
- Step {approver.level}: {step.name} + {step?.name || 'Unknown'} {hours} hours
); @@ -567,6 +1106,188 @@ export function ClaimApproverSelectionStep({
+ + {/* Add Additional Approver Modal */} + + + + + + Add Additional Approver + + + Add an additional approver between workflow steps. The approver will be inserted after the selected step. Additional approvers cannot be added after "Requestor Claim Approval". + + + +
+ {/* Insert After Level Selection */} +
+ + +

+ The new approver will be inserted after the selected step. +

+

+ ⚠️ Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final. +

+
+ + {/* TAT Input */} +
+ +
+ { + const value = e.target.value; + // Allow empty input while typing + if (value === '') { + setAddApproverTat(''); + } else { + const numValue = Number(value); + if (!isNaN(numValue) && numValue >= 0) { + setAddApproverTat(numValue); + } + } + }} + onBlur={(e) => { + // If empty on blur, reset to default + const value = e.target.value; + if (!value || value === '' || Number(value) <= 0) { + setAddApproverTat(24); + } + }} + className="h-11 border-gray-300 flex-1" + placeholder="24" + /> + +
+

+ Maximum time for this approver to respond (1-{addApproverTatType === 'days' ? '30 days' : '720 hours'}) +

+
+ + {/* Email Input with @ Search */} +
+ +
+ + handleAddApproverEmailChange(e.target.value)} + className="pl-10 h-11 border-gray-300" + autoFocus + /> + + {/* Search Results Dropdown */} + {(isSearchingApprover || addApproverSearchResults.length > 0) && ( +
+ {isSearchingApprover ? ( +
Searching users...
+ ) : addApproverSearchResults.length > 0 ? ( +
    + {addApproverSearchResults.map((user) => ( +
  • handleSelectAddApproverUser(user)} + > +
    +
    +

    + {user.displayName || [user.firstName, user.lastName].filter(Boolean).join(' ') || user.email} +

    +

    {user.email}

    + {user.designation && ( +

    {user.designation}

    + )} +
    +
    +
  • + ))} +
+ ) : null} +
+ )} +
+

+ Type @username to search for users, or enter email directly. +

+
+
+ + + + + +
+
); } diff --git a/src/dealer-claim/components/request-creation/ClaimManagementWizard.tsx b/src/dealer-claim/components/request-creation/ClaimManagementWizard.tsx index 5b68d5d..daa5266 100644 --- a/src/dealer-claim/components/request-creation/ClaimManagementWizard.tsx +++ b/src/dealer-claim/components/request-creation/ClaimManagementWizard.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -23,6 +24,7 @@ import { Info, FileText, Users, + AlertCircle, } from 'lucide-react'; import { format } from 'date-fns'; import { toast } from 'sonner'; @@ -30,6 +32,18 @@ import { getAllDealers as fetchDealersFromAPI, getDealerByCode, type DealerInfo import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep'; import { useAuth } from '@/contexts/AuthContext'; +// CLAIM_STEPS definition (same as in ClaimApproverSelectionStep) +const CLAIM_STEPS = [ + { level: 1, name: 'Dealer Proposal Submission', description: 'Dealer submits proposal documents', defaultTat: 72, isAuto: false, approverType: 'dealer' }, + { level: 2, name: 'Requestor Evaluation', description: 'Initiator evaluates dealer proposal', defaultTat: 48, isAuto: false, approverType: 'initiator' }, + { level: 3, name: 'Department Lead Approval', description: 'Department lead approves and blocks IO budget', defaultTat: 72, isAuto: false, approverType: 'manual' }, + { level: 4, name: 'Activity Creation', description: 'System auto-processes activity creation', defaultTat: 1, isAuto: true, approverType: 'system' }, + { level: 5, name: 'Dealer Completion Documents', description: 'Dealer submits completion documents', defaultTat: 120, isAuto: false, approverType: 'dealer' }, + { level: 6, name: 'Requestor Claim Approval', description: 'Initiator approves completion', defaultTat: 48, isAuto: false, approverType: 'initiator' }, + { level: 7, name: 'E-Invoice Generation', description: 'System generates e-invoice via DMS', defaultTat: 1, isAuto: true, approverType: 'system' }, + { level: 8, name: 'Credit Note Confirmation', description: 'System/Finance processes credit note confirmation', defaultTat: 48, isAuto: true, approverType: 'system' }, +]; + interface ClaimManagementWizardProps { onBack?: () => void; onSubmit?: (claimData: any) => void; @@ -59,9 +73,11 @@ const STEP_NAMES = [ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) { const { user } = useAuth(); + const navigate = useNavigate(); const [currentStep, setCurrentStep] = useState(1); const [dealers, setDealers] = useState([]); const [loadingDealers, setLoadingDealers] = useState(true); + const [isDealerUser, setIsDealerUser] = useState(false); const [formData, setFormData] = useState({ activityName: '', @@ -85,13 +101,47 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar level: number; tat?: number | string; tatType?: 'hours' | 'days'; + isAdditional?: boolean; + insertAfterLevel?: number; + stepName?: string; + originalStepLevel?: number; }> }); const totalSteps = STEP_NAMES.length; + // Check if user is a Dealer and prevent access + useEffect(() => { + const userDesignation = (user as any)?.designation?.toLowerCase() || ''; + const isDealer = userDesignation === 'dealer' || userDesignation.includes('dealer'); + + if (isDealer) { + setIsDealerUser(true); + toast.error('Dealers are not allowed to create claim requests. Please contact your administrator.'); + console.warn('Dealer user attempted to create claim request:', { + userId: (user as any)?.userId, + email: (user as any)?.email, + designation: (user as any)?.designation, + }); + + // Redirect back after a short delay + setTimeout(() => { + if (onBack) { + onBack(); + } else { + navigate('/'); + } + }, 2000); + } + }, [user, navigate, onBack]); + // Fetch dealers from API on component mount useEffect(() => { + // Don't fetch dealers if user is a Dealer + if (isDealerUser) { + return; + } + const fetchDealers = async () => { setLoadingDealers(true); try { @@ -105,7 +155,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar } }; fetchDealers(); - }, []); + }, [isDealerUser]); const updateFormData = (field: string, value: any) => { setFormData(prev => { @@ -145,7 +195,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar case 2: // Validate that all required approvers are assigned (Step 3 only, Step 8 is now system/Finance) const approvers = formData.approvers || []; - const step3Approver = approvers.find((a: any) => a.level === 3); + // Find step 3 approver by originalStepLevel first, then fallback to level + const step3Approver = approvers.find((a: any) => + a.originalStepLevel === 3 || (!a.originalStepLevel && a.level === 3 && !a.isAdditional) + ); // Step 8 is now a system step, no validation needed return step3Approver?.email && step3Approver?.userId && step3Approver?.tat; case 3: @@ -161,11 +214,14 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar // Show specific error messages for step 2 (approver selection) if (currentStep === 2) { const approvers = formData.approvers || []; - const step3Approver = approvers.find((a: any) => a.level === 3); + // Find step 3 approver by originalStepLevel first, then fallback to level + const step3Approver = approvers.find((a: any) => + a.originalStepLevel === 3 || (!a.originalStepLevel && a.level === 3 && !a.isAdditional) + ); const missingSteps: string[] = []; if (!step3Approver?.email || !step3Approver?.userId || !step3Approver?.tat) { - missingSteps.push('Step 3: Department Lead Approval'); + missingSteps.push('Department Lead Approval'); } if (missingSteps.length > 0) { @@ -212,14 +268,64 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar }; const handleSubmit = () => { + // Approvers are already using integer levels with proper shifting + // Just sort them and prepare for submission + const approvers = formData.approvers || []; + const sortedApprovers = [...approvers].sort((a, b) => a.level - b.level); + + // Check for duplicate levels (should not happen, but safeguard) + const levelMap = new Map(); + const duplicates: number[] = []; + + sortedApprovers.forEach((approver) => { + if (levelMap.has(approver.level)) { + duplicates.push(approver.level); + } else { + levelMap.set(approver.level, approver); + } + }); + + if (duplicates.length > 0) { + toast.error(`Duplicate approver levels detected: ${duplicates.join(', ')}. Please refresh and try again.`); + console.error('Duplicate levels found:', duplicates, sortedApprovers); + return; + } + + // Prepare final approvers array - preserve stepName for additional approvers + // The backend will use stepName to set the levelName for approval levels + // Also preserve originalStepLevel so backend can identify which step each approver belongs to + const finalApprovers = sortedApprovers.map((approver) => { + const result: any = { + email: approver.email, + name: approver.name, + userId: approver.userId, + level: approver.level, + tat: approver.tat, + tatType: approver.tatType, + }; + + // Preserve stepName for additional approvers + if (approver.isAdditional && approver.stepName) { + result.stepName = approver.stepName; + result.isAdditional = true; + } + + // Preserve originalStepLevel for fixed steps (so backend can identify which step this is) + if (approver.originalStepLevel) { + result.originalStepLevel = approver.originalStepLevel; + } + + return result; + }); + const claimData = { ...formData, templateType: 'claim-management', submittedAt: new Date().toISOString(), status: 'pending', currentStep: 'initiator-review', - // Pass approvers array to backend - approvers: formData.approvers || [] + // Pass normalized approvers array to backend + approvers: finalApprovers }; // Don't show toast here - let the parent component handle success/error after API call @@ -552,41 +658,65 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
- {(formData.approvers || []).filter((a: any) => !a.email?.includes('system@')).map((approver: any) => { - const stepNames: Record = { - 1: 'Dealer Proposal Submission', - 2: 'Requestor Evaluation', - 3: 'Department Lead Approval', - 4: 'Activity Creation', - 5: 'Dealer Completion Documents', - 6: 'Requestor Claim Approval', - 7: 'E-Invoice Generation', - 8: 'Credit Note Confirmation', - }; - const tat = Number(approver.tat || 0); - const tatType = approver.tatType || 'hours'; - const hours = tatType === 'days' ? tat * 24 : tat; + {(() => { + // Sort approvers by level and filter out system approvers + const sortedApprovers = [...(formData.approvers || [])] + .filter((a: any) => !a.email?.includes('system@') && !a.email?.includes('finance@')) + .sort((a: any, b: any) => a.level - b.level); - return ( -
-
-
- -

{approver.name || approver.email || 'Not selected'}

- {approver.email && ( -

{approver.email}

- )} -
-
-

{hours} hours

-

TAT

+ return sortedApprovers.map((approver: any) => { + const tat = Number(approver.tat || 0); + const tatType = approver.tatType || 'hours'; + const hours = tatType === 'days' ? tat * 24 : tat; + + // Find step name - handle additional approvers and shifted levels + let stepName = 'Unknown'; + let stepLabel = ''; + + if (approver.isAdditional) { + // Additional approver - use stepName if available + stepName = approver.stepName || 'Additional Approver'; + const afterStep = CLAIM_STEPS.find(s => s.level === approver.insertAfterLevel); + stepLabel = approver.stepName || `Additional Approver (after "${afterStep?.name || 'Unknown'}")`; + } else { + // Fixed step - find by originalStepLevel first, then fallback to level + const step = approver.originalStepLevel + ? CLAIM_STEPS.find(s => s.level === approver.originalStepLevel) + : CLAIM_STEPS.find(s => s.level === approver.level && !s.isAuto); + stepName = step?.name || 'Unknown'; + stepLabel = stepName; + } + + return ( +
+
+
+
+ + {approver.isAdditional && ( + + ADDITIONAL + + )} +
+

{approver.name || approver.email || 'Not selected'}

+ {approver.email && ( +

{approver.email}

+ )} +
+
+

{hours} hours

+

TAT

+
-
- ); - })} + ); + }); + })()}
@@ -691,6 +821,51 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar } }; + // Show access denied message if user is a Dealer + if (isDealerUser) { + return ( +
+
+
+ +
+ + + +
+
+ +
+

Access Denied

+

+ Dealers are not allowed to create claim requests. Only internal employees can initiate claim requests. +

+

+ If you believe this is an error, please contact your administrator. +

+ +
+
+
+
+
+ ); + } + return (
diff --git a/src/dealer-claim/components/request-detail/IOTab.tsx b/src/dealer-claim/components/request-detail/IOTab.tsx index b384b22..2d166c6 100644 --- a/src/dealer-claim/components/request-detail/IOTab.tsx +++ b/src/dealer-claim/components/request-detail/IOTab.tsx @@ -151,6 +151,11 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) { return; } + if (!ioRemark.trim()) { + toast.error('Please enter an IO remark'); + return; + } + if (!requestId) { toast.error('Request ID not found'); return; @@ -341,8 +346,8 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) { {/* IO Remark Input */}
-