- {/* 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({
+ // 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 (
+
@@ -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 */}
-
@@ -1080,7 +1096,7 @@ export function DealerClaimWorkflowTab({
- Step {step.step}: {step.title}
+ {step.title}
{step.status.toLowerCase()}
@@ -1224,46 +1240,63 @@ export function DealerClaimWorkflowTab({
)}
- {/* IO Organization Details (Step 3) - Show when step is approved and has IO details */}
- {step.step === 3 && step.status === 'approved' && step.ioDetails && step.ioDetails.ioNumber && (
-
- {step.ioDetails.ioRemark || 'N/A'}
+ {/* IO Organization Details (Department Lead Approval) - Show when step is approved and has IO details */}
+ {(() => {
+ // Check if this is Department Lead Approval step by step title or levelName (handles step shifts)
+ // Use levelName from the original step data if available, otherwise check title
+ const stepLevelName = (step as any).levelName || (step as any).level_name;
+ const isDeptLeadStep =
+ (stepLevelName && stepLevelName.toLowerCase().includes('department lead')) ||
+ (step.title && step.title.toLowerCase().includes('department lead'));
+ // Only show when step is approved and has IO details
+ return isDeptLeadStep && step.status === 'approved' && step.ioDetails && step.ioDetails.ioNumber && (
+