dealer claim steps reduced and the modal popup layout changed and tanflow login added

This commit is contained in:
laxmanhalaki 2025-12-22 19:56:10 +05:30
parent 01d69bb1eb
commit ce90fcf9ef
20 changed files with 2558 additions and 756 deletions

View File

@ -567,7 +567,7 @@ function AppRoutes({ onLogout }: AppProps) {
return (
<div className="min-h-screen h-screen flex flex-col overflow-hidden bg-background">
<Routes>
{/* Auth Callback - Must be before other routes */}
{/* Auth Callback - Unified callback for both OKTA and Tanflow */}
<Route
path="/login/callback"
element={<AuthCallback />}

View File

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

View File

@ -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<DealerInfo[]>([]);
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<number, typeof sortedApprovers[0]>();
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
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div className="space-y-3">
{(formData.approvers || []).filter((a: any) => !a.email?.includes('system@')).map((approver: any) => {
const stepNames: Record<number, string> = {
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 (
<div key={approver.level} className="p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center justify-between">
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">
Step {approver.level}: {stepNames[approver.level]}
</Label>
<p className="font-semibold text-gray-900 mt-1">{approver.name || approver.email || 'Not selected'}</p>
{approver.email && (
<p className="text-xs text-gray-500 mt-1">{approver.email}</p>
)}
</div>
<div className="text-right">
<p className="text-sm font-semibold text-gray-900">{hours} hours</p>
<p className="text-xs text-gray-500">TAT</p>
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 (
<div key={`${approver.level}-${approver.email}`} className={`p-3 rounded-lg border ${
approver.isAdditional ? 'bg-purple-50 border-purple-200' : 'bg-gray-50 border-gray-200'
}`}>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Label className="text-xs text-gray-600 uppercase tracking-wider">
{stepLabel}
</Label>
{approver.isAdditional && (
<Badge variant="outline" className="text-xs bg-purple-100 text-purple-700 border-purple-300">
ADDITIONAL
</Badge>
)}
</div>
<p className="font-semibold text-gray-900 mt-1">{approver.name || approver.email || 'Not selected'}</p>
{approver.email && (
<p className="text-xs text-gray-500 mt-1">{approver.email}</p>
)}
</div>
<div className="text-right ml-4">
<p className="text-sm font-semibold text-gray-900">{hours} hours</p>
<p className="text-xs text-gray-500">TAT</p>
</div>
</div>
</div>
</div>
);
})}
);
});
})()}
</div>
</CardContent>
</Card>
@ -691,6 +821,51 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
}
};
// Show access denied message if user is a Dealer
if (isDealerUser) {
return (
<div className="w-full bg-gradient-to-br from-gray-50 to-gray-100 py-4 sm:py-6 lg:py-8 px-3 sm:px-4 lg:px-6 overflow-y-auto">
<div className="max-w-6xl mx-auto pb-8">
<div className="mb-6 sm:mb-8">
<Button
variant="ghost"
onClick={onBack || (() => navigate('/'))}
className="mb-3 sm:mb-4 gap-2 text-sm sm:text-base"
>
<ArrowLeft className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">Back to Dashboard</span>
<span className="sm:hidden">Back</span>
</Button>
</div>
<Card className="max-w-2xl mx-auto">
<CardContent className="p-8">
<div className="text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<AlertCircle className="w-8 h-8 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Access Denied</h2>
<p className="text-gray-600 mb-6">
Dealers are not allowed to create claim requests. Only internal employees can initiate claim requests.
</p>
<p className="text-sm text-gray-500 mb-6">
If you believe this is an error, please contact your administrator.
</p>
<Button
onClick={onBack || (() => navigate('/'))}
className="gap-2"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className="w-full bg-gradient-to-br from-gray-50 to-gray-100 py-4 sm:py-6 lg:py-8 px-3 sm:px-4 lg:px-6 overflow-y-auto">
<div className="max-w-6xl mx-auto pb-8">

View File

@ -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 */}
<div className="space-y-2">
<Label htmlFor="ioRemark" className="text-sm font-medium text-gray-900">
IO Remark
<Label htmlFor="ioRemark" className="text-sm font-medium text-gray-900 flex items-center gap-2">
IO Remark <span className="text-red-500">*</span>
</Label>
<Textarea
id="ioRemark"
@ -357,6 +362,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
rows={3}
disabled={!!blockedDetails}
className="bg-white text-sm min-h-[80px] resize-none"
required
/>
<div className="flex justify-end text-xs text-gray-600">
<span>{ioRemarkChars}/{maxIoRemarkChars}</span>
@ -367,7 +373,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
{!fetchedAmount && !blockedDetails && ioNumber.trim() && (
<Button
onClick={handleSaveIODetails}
disabled={blockingBudget || !ioNumber.trim()}
disabled={blockingBudget || !ioNumber.trim() || !ioRemark.trim()}
variant="outline"
className="w-full border-[#2d4a3e] text-[#2d4a3e] hover:bg-[#2d4a3e] hover:text-white"
>

View File

@ -208,13 +208,15 @@ export function DealerClaimWorkflowTab({
if (prevFlows.length !== flows.length) {
return flows;
}
// Check if any levelNumber or levelName changed
// Check if any levelNumber, levelName, approverEmail, or status changed
// Status change is critical - when an approval happens, status changes from 'in_progress' to 'approved'
const hasChanges = prevFlows.some((prev: any, idx: number) => {
const curr = flows[idx];
return !curr ||
prev.levelNumber !== curr.levelNumber ||
prev.levelName !== curr.levelName ||
prev.approverEmail !== curr.approverEmail;
prev.approverEmail !== curr.approverEmail ||
prev.status !== curr.status; // Check status change
});
return hasChanges ? flows : prevFlows;
});
@ -276,8 +278,10 @@ export function DealerClaimWorkflowTab({
}, [request?.currentStep, request?.totalLevels]);
// Enhanced refresh handler that also reloads approval flows
const handleRefresh = () => {
const handleRefresh = async () => {
setRefreshTrigger(prev => prev + 1);
// Small delay to ensure backend has fully processed the approval
await new Promise(resolve => setTimeout(resolve, 500));
onRefresh?.();
};
@ -461,10 +465,27 @@ export function DealerClaimWorkflowTab({
}
// Normalize status - handle "in-review" and other variations
let normalizedStatus = (step.status || 'waiting').toLowerCase();
// Check both step.status and approval.status (approval status is more accurate after approval)
const stepStatus = step.status || approval?.status || 'waiting';
let normalizedStatus = stepStatus.toLowerCase();
// Handle status variations
if (normalizedStatus === 'in-review' || normalizedStatus === 'in_review' || normalizedStatus === 'in review') {
normalizedStatus = 'in_progress';
}
// If approval exists and has a status, prefer that (it's more up-to-date)
if (approval?.status) {
const approvalStatus = approval.status.toLowerCase();
// Map backend status values to frontend status values
if (approvalStatus === 'approved') {
normalizedStatus = 'approved';
} else if (approvalStatus === 'rejected') {
normalizedStatus = 'rejected';
} else if (approvalStatus === 'pending' || approvalStatus === 'in_progress' || approvalStatus === 'in-progress') {
normalizedStatus = 'in_progress';
}
}
// Business logic: Only show elapsed time for active or completed steps
// Waiting steps (future steps) should have elapsedHours = 0
@ -493,8 +514,6 @@ export function DealerClaimWorkflowTab({
emailTemplateUrl: (approval as any)?.emailTemplateUrl || undefined,
};
});
const totalSteps = request?.totalSteps || 8;
// Calculate currentStep from approval flow - find the first pending or in_progress step
// IMPORTANT: Use the workflow's currentLevel from backend (most accurate)
@ -1027,12 +1046,9 @@ export function DealerClaimWorkflowTab({
Claim Management Workflow
</CardTitle>
<CardDescription className="mt-2">
8-Step approval process for dealer claim management
Approval process for dealer claim management
</CardDescription>
</div>
<Badge variant="outline" className="font-medium">
Step {currentStep} of {totalSteps}
</Badge>
</div>
</CardHeader>
<CardContent>
@ -1080,7 +1096,7 @@ export function DealerClaimWorkflowTab({
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-gray-900">
Step {step.step}: {step.title}
{step.title}
</h4>
<Badge className={getStepBadgeVariant(step.status)}>
{step.status.toLowerCase()}
@ -1224,46 +1240,63 @@ export function DealerClaimWorkflowTab({
</div>
)}
{/* 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 && (
<div className="mt-3 p-3 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center gap-2 mb-2">
<Receipt className="w-4 h-4 text-blue-600" />
<p className="text-xs font-semibold text-blue-900 uppercase tracking-wide">
IO Organisation Details
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600">IO Number:</span>
<span className="text-sm font-semibold text-gray-900">
{step.ioDetails.ioNumber}
</span>
</div>
{step.ioDetails.blockedAmount !== undefined && step.ioDetails.blockedAmount > 0 && (
<div className="flex items-center justify-between pt-1.5 border-t border-blue-100">
<span className="text-xs text-gray-600">Blocked Amount:</span>
<span className="text-sm font-bold text-green-700">
{step.ioDetails.blockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
)}
<div className="pt-1.5 border-t border-blue-100">
<p className="text-xs text-gray-600 mb-1">IO Remark:</p>
<p className="text-sm text-gray-900">
{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 && (
<div className="mt-3 p-3 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center gap-2 mb-2">
<Receipt className="w-4 h-4 text-blue-600" />
<p className="text-xs font-semibold text-blue-900 uppercase tracking-wide">
IO Organisation Details
</p>
</div>
<div className="pt-1.5 border-t border-blue-100 text-xs text-gray-500">
Organised by {step.ioDetails.organizedBy || step.approver || 'N/A'} on{' '}
{step.ioDetails.organizedAt
? formatDateSafe(step.ioDetails.organizedAt)
: (step.approvedAt ? formatDateSafe(step.approvedAt) : 'N/A')
}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600">IO Number:</span>
<span className="text-sm font-semibold text-gray-900">
{step.ioDetails.ioNumber}
</span>
</div>
{step.ioDetails.blockedAmount !== undefined && step.ioDetails.blockedAmount > 0 && (
<div className="flex items-center justify-between pt-1.5 border-t border-blue-100">
<span className="text-xs text-gray-600">Blocked Amount:</span>
<span className="text-sm font-bold text-green-700">
{step.ioDetails.blockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
)}
{step.ioDetails.remainingBalance !== undefined && step.ioDetails.remainingBalance !== null && (
<div className="flex items-center justify-between pt-1.5 border-t border-blue-100">
<span className="text-xs text-gray-600">Remaining Balance:</span>
<span className="text-sm font-semibold text-blue-700">
{step.ioDetails.remainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
)}
<div className="pt-1.5 border-t border-blue-100">
<p className="text-xs text-gray-600 mb-1">IO Remark:</p>
<p className="text-sm text-gray-900">
{step.ioDetails.ioRemark || 'N/A'}
</p>
</div>
<div className="pt-1.5 border-t border-blue-100 text-xs text-gray-500">
Organised by {step.ioDetails.organizedBy || step.approver || 'N/A'} on{' '}
{step.ioDetails.organizedAt
? formatDateSafe(step.ioDetails.organizedAt)
: (step.approvedAt ? formatDateSafe(step.approvedAt) : 'N/A')
}
</div>
</div>
</div>
</div>
)}
);
})()}
{/* DMS Processing Details (Step 6) */}
{step.step === 6 && step.dmsDetails && step.dmsDetails.dmsNumber && (
@ -1369,18 +1402,7 @@ export function DealerClaimWorkflowTab({
const hasIONumber = ioNumber && ioNumber.trim() !== '';
return (
<div className="space-y-2">
{!hasIONumber && (
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-amber-800">IO Number Not Available</p>
<p className="text-xs text-amber-700 mt-1">
Please add an IO number in the IO tab before approving this step.
</p>
</div>
</div>
)}
<div className="space-y-1">
<Button
className="bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
@ -1391,6 +1413,11 @@ export function DealerClaimWorkflowTab({
<CheckCircle className="w-4 h-4 mr-2" />
Approve and Organise IO
</Button>
{!hasIONumber && (
<p className="text-xs text-amber-600">
Please add an IO number in the IO tab before approving this step.
</p>
)}
</div>
);
})()}
@ -1421,22 +1448,38 @@ export function DealerClaimWorkflowTab({
</Button>
)}
{/* Step 6: Push to DMS - Only for initiator or step 6 approver */}
{step.step === 6 && (isInitiator || (() => {
const step6Level = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 6);
const step6ApproverEmail = (step6Level?.approverEmail || '').toLowerCase();
return step6ApproverEmail && userEmail === step6ApproverEmail;
})()) && (
<Button
className="bg-indigo-600 hover:bg-indigo-700"
onClick={() => {
setShowDMSPushModal(true);
}}
>
<Activity className="w-4 h-4 mr-2" />
Push to DMS
</Button>
)}
{/* Requestor Claim Approval: Push to DMS - Find dynamically by levelName (handles step shifts) */}
{(() => {
// Find Requestor Claim Approval step dynamically (handles step shifts)
const requestorClaimStepLevel = approvalFlow.find((l: any) => {
const levelName = (l.levelName || '').toLowerCase();
return levelName.includes('requestor claim') || levelName.includes('requestor - claim');
});
// Check if this is the Requestor Claim Approval step
const isRequestorClaimStep = requestorClaimStepLevel &&
(step.step === (requestorClaimStepLevel.step || requestorClaimStepLevel.levelNumber || requestorClaimStepLevel.level_number));
if (!isRequestorClaimStep) return null;
// Check if user is the initiator or the Requestor Claim Approval approver
const requestorClaimApproverEmail = (requestorClaimStepLevel?.approverEmail || '').toLowerCase();
const isRequestorClaimApprover = requestorClaimApproverEmail && userEmail === requestorClaimApproverEmail;
if (!(isInitiator || isRequestorClaimApprover)) return null;
return (
<Button
className="bg-indigo-600 hover:bg-indigo-700"
onClick={() => {
setShowDMSPushModal(true);
}}
>
<Activity className="w-4 h-4 mr-2" />
Push to DMS
</Button>
);
})()}
{/* Step 8: View & Send Credit Note - Only for finance approver or step 8 approver */}
{step.step === 8 && (() => {

View File

@ -0,0 +1,37 @@
.dms-push-modal {
width: 90vw !important;
max-width: 90vw !important;
max-height: 95vh !important;
}
/* Mobile responsive */
@media (max-width: 640px) {
.dms-push-modal {
width: 95vw !important;
max-width: 95vw !important;
max-height: 95vh !important;
}
}
/* Large screens - use more width */
@media (min-width: 1024px) {
.dms-push-modal {
width: 85vw !important;
max-width: 85vw !important;
}
}
@media (min-width: 1280px) {
.dms-push-modal {
width: 80vw !important;
max-width: 80vw !important;
}
}
@media (min-width: 1536px) {
.dms-push-modal {
width: 75vw !important;
max-width: 75vw !important;
}
}

View File

@ -26,6 +26,7 @@ import {
CheckCircle2,
} from 'lucide-react';
import { toast } from 'sonner';
import './DMSPushModal.css';
interface ExpenseItem {
description: string;
@ -138,41 +139,41 @@ export function DMSPushModal({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-indigo-100">
<Activity className="w-6 h-6 text-indigo-600" />
<DialogContent className="dms-push-modal overflow-hidden flex flex-col">
<DialogHeader className="px-6 pt-6 pb-3 flex-shrink-0">
<div className="flex items-center gap-2 sm:gap-3 mb-2">
<div className="p-1.5 sm:p-2 rounded-lg bg-indigo-100">
<Activity className="w-4 h-4 sm:w-5 sm:h-5 sm:w-6 sm:h-6 text-indigo-600" />
</div>
<div className="flex-1">
<DialogTitle className="font-semibold text-xl">
<DialogTitle className="font-semibold text-lg sm:text-xl">
Push to DMS - Verification
</DialogTitle>
<DialogDescription className="text-sm mt-1">
<DialogDescription className="text-xs sm:text-sm mt-1">
Review completion details and expenses before pushing to DMS for e-invoice generation
</DialogDescription>
</div>
</div>
{/* Request Info Card */}
<div className="space-y-3 p-4 bg-gray-50 rounded-lg border">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">Workflow Step:</span>
<Badge variant="outline" className="font-mono">Step 6</Badge>
{/* Request Info Card - Grid layout */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 p-3 sm:p-4 bg-gray-50 rounded-lg border">
<div className="flex items-center justify-between sm:flex-col sm:items-start sm:gap-1">
<span className="font-medium text-xs sm:text-sm text-gray-600">Workflow Step:</span>
<Badge variant="outline" className="font-mono text-xs">Requestor Claim Approval</Badge>
</div>
{requestNumber && (
<div>
<span className="font-medium text-gray-900">Request Number:</span>
<p className="text-gray-700 mt-1 font-mono">{requestNumber}</p>
<div className="flex flex-col gap-1">
<span className="font-medium text-xs sm:text-sm text-gray-600">Request Number:</span>
<p className="text-gray-700 font-mono text-xs sm:text-sm">{requestNumber}</p>
</div>
)}
<div>
<span className="font-medium text-gray-900">Title:</span>
<p className="text-gray-700 mt-1">{requestTitle || '—'}</p>
<div className="flex flex-col gap-1">
<span className="font-medium text-xs sm:text-sm text-gray-600">Title:</span>
<p className="text-gray-700 text-xs sm:text-sm line-clamp-2">{requestTitle || '—'}</p>
</div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">Action:</span>
<Badge className="bg-indigo-100 text-indigo-800 border-indigo-200">
<div className="flex items-center gap-2 sm:flex-col sm:items-start sm:gap-1">
<span className="font-medium text-xs sm:text-sm text-gray-600">Action:</span>
<Badge className="bg-indigo-100 text-indigo-800 border-indigo-200 text-xs">
<Activity className="w-3 h-3 mr-1" />
PUSH TO DMS
</Badge>
@ -180,147 +181,151 @@ export function DMSPushModal({
</div>
</DialogHeader>
<div className="space-y-4">
{/* Completion Details Card */}
{completionDetails && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<CheckCircle2 className="w-5 h-5 text-green-600" />
Completion Details
</CardTitle>
<CardDescription>
Review activity completion information
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{completionDetails.activityCompletionDate && (
<div className="flex items-center justify-between py-2 border-b">
<span className="text-sm text-gray-600">Activity Completion Date:</span>
<span className="text-sm font-semibold text-gray-900">
{formatDate(completionDetails.activityCompletionDate)}
</span>
</div>
)}
{completionDetails.numberOfParticipants !== undefined && (
<div className="flex items-center justify-between py-2 border-b">
<span className="text-sm text-gray-600">Number of Participants:</span>
<span className="text-sm font-semibold text-gray-900">
{completionDetails.numberOfParticipants}
</span>
</div>
)}
{completionDetails.completionDescription && (
<div className="pt-2">
<p className="text-xs text-gray-600 mb-1">Completion Description:</p>
<p className="text-sm text-gray-900">
{completionDetails.completionDescription}
</p>
</div>
)}
</CardContent>
</Card>
)}
{/* Expense Breakdown Card */}
{completionDetails?.closedExpenses && completionDetails.closedExpenses.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<DollarSign className="w-5 h-5 text-blue-600" />
Expense Breakdown
</CardTitle>
<CardDescription>
Review closed expenses before pushing to DMS
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{completionDetails.closedExpenses.map((expense, index) => (
<div
key={index}
className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded border"
>
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">
{expense.description || `Expense ${index + 1}`}
</p>
</div>
<div className="ml-4">
<p className="text-sm font-semibold text-gray-900">
{formatCurrency(typeof expense === 'object' ? (expense.amount || 0) : 0)}
</p>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-3">
<div className="space-y-3 sm:space-y-4 max-w-7xl mx-auto">
{/* Grid layout for all three cards on larger screens */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{/* Completion Details Card */}
{completionDetails && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
<CheckCircle2 className="w-4 h-4 sm:w-5 sm:h-5 text-green-600" />
Completion Details
</CardTitle>
<CardDescription className="text-xs sm:text-sm">
Review activity completion information
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 sm:space-y-3">
{completionDetails.activityCompletionDate && (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
<span className="text-xs sm:text-sm text-gray-600">Activity Completion Date:</span>
<span className="text-xs sm:text-sm font-semibold text-gray-900">
{formatDate(completionDetails.activityCompletionDate)}
</span>
</div>
))}
<div className="flex items-center justify-between py-3 px-3 bg-blue-50 rounded border-2 border-blue-200 mt-3">
<span className="text-sm font-semibold text-gray-900">Total Closed Expenses:</span>
<span className="text-lg font-bold text-blue-700">
)}
{completionDetails.numberOfParticipants !== undefined && (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
<span className="text-xs sm:text-sm text-gray-600">Number of Participants:</span>
<span className="text-xs sm:text-sm font-semibold text-gray-900">
{completionDetails.numberOfParticipants}
</span>
</div>
)}
{completionDetails.completionDescription && (
<div className="pt-2">
<p className="text-xs text-gray-600 mb-1">Completion Description:</p>
<p className="text-xs sm:text-sm text-gray-900 line-clamp-3">
{completionDetails.completionDescription}
</p>
</div>
)}
</CardContent>
</Card>
)}
{/* IO Details Card */}
{ioDetails && ioDetails.ioNumber && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
<Receipt className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
IO Details
</CardTitle>
<CardDescription className="text-xs sm:text-sm">
Internal Order information for budget reference
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 sm:space-y-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
<span className="text-xs sm:text-sm text-gray-600">IO Number:</span>
<span className="text-xs sm:text-sm font-semibold text-gray-900 font-mono">
{ioDetails.ioNumber}
</span>
</div>
{ioDetails.blockedAmount !== undefined && ioDetails.blockedAmount > 0 && (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
<span className="text-xs sm:text-sm text-gray-600">Blocked Amount:</span>
<span className="text-xs sm:text-sm font-bold text-green-700">
{formatCurrency(ioDetails.blockedAmount)}
</span>
</div>
)}
{ioDetails.remainingBalance !== undefined && ioDetails.remainingBalance !== null && (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2">
<span className="text-xs sm:text-sm text-gray-600">Remaining Balance:</span>
<span className="text-xs sm:text-sm font-semibold text-gray-900">
{formatCurrency(ioDetails.remainingBalance)}
</span>
</div>
)}
</CardContent>
</Card>
)}
{/* Expense Breakdown Card */}
{completionDetails?.closedExpenses && completionDetails.closedExpenses.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
<DollarSign className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
Expense Breakdown
</CardTitle>
<CardDescription className="text-xs sm:text-sm">
Review closed expenses before pushing to DMS
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-1.5 sm:space-y-2 max-h-[200px] overflow-y-auto">
{completionDetails.closedExpenses.map((expense, index) => (
<div
key={index}
className="flex items-center justify-between py-1.5 sm:py-2 px-2 sm:px-3 bg-gray-50 rounded border"
>
<div className="flex-1 min-w-0 pr-2">
<p className="text-xs sm:text-sm font-medium text-gray-900 truncate">
{expense.description || `Expense ${index + 1}`}
</p>
</div>
<div className="ml-2 flex-shrink-0">
<p className="text-xs sm:text-sm font-semibold text-gray-900">
{formatCurrency(typeof expense === 'object' ? (expense.amount || 0) : 0)}
</p>
</div>
</div>
))}
</div>
<div className="flex items-center justify-between py-2 sm:py-3 px-2 sm:px-3 bg-blue-50 rounded border-2 border-blue-200 mt-2 sm:mt-3">
<span className="text-xs sm:text-sm font-semibold text-gray-900">Total:</span>
<span className="text-sm sm:text-base font-bold text-blue-700">
{formatCurrency(totalClosedExpenses)}
</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* IO Details Card */}
{ioDetails && ioDetails.ioNumber && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Receipt className="w-5 h-5 text-purple-600" />
IO Details
</CardTitle>
<CardDescription>
Internal Order information for budget reference
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between py-2 border-b">
<span className="text-sm text-gray-600">IO Number:</span>
<span className="text-sm font-semibold text-gray-900 font-mono">
{ioDetails.ioNumber}
</span>
</div>
{ioDetails.blockedAmount !== undefined && ioDetails.blockedAmount > 0 && (
<div className="flex items-center justify-between py-2 border-b">
<span className="text-sm text-gray-600">Blocked Amount:</span>
<span className="text-sm font-bold text-green-700">
{formatCurrency(ioDetails.blockedAmount)}
</span>
</div>
)}
{ioDetails.remainingBalance !== undefined && ioDetails.remainingBalance !== null && (
<div className="flex items-center justify-between py-2">
<span className="text-sm text-gray-600">Remaining Balance:</span>
<span className="text-sm font-semibold text-gray-900">
{formatCurrency(ioDetails.remainingBalance)}
</span>
</div>
)}
</CardContent>
</Card>
)}
</CardContent>
</Card>
)}
</div>
{/* Verification Warning */}
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="p-2.5 sm:p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-start gap-2">
<TriangleAlert className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
<TriangleAlert className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-yellow-900">
<p className="text-xs sm:text-sm font-semibold text-yellow-900">
Please verify all details before pushing to DMS
</p>
<p className="text-xs text-yellow-700 mt-1">
Once pushed, the system will automatically generate an e-invoice and the workflow will proceed to Step 7.
Once pushed, the system will automatically generate an e-invoice and log it as an activity.
</p>
</div>
</div>
</div>
{/* Comments & Remarks */}
<div className="space-y-1.5">
<Label htmlFor="comment" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
<div className="space-y-1.5 max-w-2xl">
<Label htmlFor="comment" className="text-xs sm:text-sm font-semibold text-gray-900 flex items-center gap-2">
Comments & Remarks <span className="text-red-500">*</span>
</Label>
<Textarea
@ -344,9 +349,10 @@ export function DMSPushModal({
<span>{commentsChars}/{maxCommentsChars}</span>
</div>
</div>
</div>
</div>
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 px-6 pt-3 pb-6 border-t flex-shrink-0">
<Button
variant="outline"
onClick={handleClose}

View File

@ -0,0 +1,37 @@
.dealer-completion-documents-modal {
width: 90vw !important;
max-width: 90vw !important;
max-height: 95vh !important;
}
/* Mobile responsive */
@media (max-width: 640px) {
.dealer-completion-documents-modal {
width: 95vw !important;
max-width: 95vw !important;
max-height: 95vh !important;
}
}
/* Large screens - use more width */
@media (min-width: 1024px) {
.dealer-completion-documents-modal {
width: 85vw !important;
max-width: 85vw !important;
}
}
@media (min-width: 1280px) {
.dealer-completion-documents-modal {
width: 80vw !important;
max-width: 80vw !important;
}
}
@media (min-width: 1536px) {
.dealer-completion-documents-modal {
width: 75vw !important;
max-width: 75vw !important;
}
}

View File

@ -21,6 +21,7 @@ import { Badge } from '@/components/ui/badge';
import { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert, CheckCircle2, Eye, Download } from 'lucide-react';
import { toast } from 'sonner';
import '@/components/common/FilePreview/FilePreview.css';
import './DealerCompletionDocumentsModal.css';
interface ExpenseItem {
id: string;
@ -295,16 +296,16 @@ export function DealerCompletionDocumentsModal({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-lg max-w-4xl max-h-[90vh] overflow-y-auto overflow-x-hidden">
<DialogHeader>
<DialogTitle className="font-semibold flex items-center gap-2 text-2xl">
<Upload className="w-6 h-6 text-[--re-green]" />
<DialogContent className="dealer-completion-documents-modal overflow-hidden flex flex-col">
<DialogHeader className="px-6 pt-6 pb-3 flex-shrink-0">
<DialogTitle className="font-semibold flex items-center gap-2 text-xl sm:text-2xl">
<Upload className="w-5 h-5 sm:w-6 sm:h-6 text-[--re-green]" />
Activity Completion Documents
</DialogTitle>
<DialogDescription className="text-base">
<DialogDescription className="text-sm sm:text-base">
Step 5: Upload completion proof and final documents
</DialogDescription>
<div className="space-y-1 mt-2 text-sm text-gray-600">
<div className="space-y-1 mt-2 text-xs sm:text-sm text-gray-600">
<div>
<strong>Dealer:</strong> {dealerName}
</div>
@ -317,11 +318,12 @@ export function DealerCompletionDocumentsModal({
</div>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-3">
<div className="space-y-5 sm:space-y-6 max-w-4xl mx-auto">
{/* Activity Completion Date */}
<div className="space-y-2">
<Label className="text-base font-semibold flex items-center gap-2" htmlFor="completionDate">
<Calendar className="w-4 h-4" />
<div className="space-y-1.5 sm:space-y-2">
<Label className="text-sm sm:text-base font-semibold flex items-center gap-2" htmlFor="completionDate">
<Calendar className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
Activity Completion Date *
</Label>
<Input
@ -330,14 +332,15 @@ export function DealerCompletionDocumentsModal({
max={maxDate}
value={activityCompletionDate}
onChange={(e) => setActivityCompletionDate(e.target.value)}
className="max-w-xs"
/>
</div>
{/* Closed Expenses Section */}
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Closed Expenses</h3>
<h3 className="font-semibold text-base sm:text-lg">Closed Expenses</h3>
<Badge className="bg-secondary text-secondary-foreground text-xs">Optional</Badge>
</div>
<Button
@ -350,7 +353,7 @@ export function DealerCompletionDocumentsModal({
Add Expense
</Button>
</div>
<div className="space-y-3">
<div className="space-y-2 sm:space-y-3">
{expenseItems.map((item) => (
<div key={item.id} className="flex gap-2 items-start">
<div className="flex-1">
@ -360,9 +363,10 @@ export function DealerCompletionDocumentsModal({
onChange={(e) =>
handleExpenseChange(item.id, 'description', e.target.value)
}
className="text-sm"
/>
</div>
<div className="w-40">
<div className="w-32 sm:w-40">
<Input
type="number"
placeholder="Amount"
@ -372,13 +376,14 @@ export function DealerCompletionDocumentsModal({
onChange={(e) =>
handleExpenseChange(item.id, 'amount', parseFloat(e.target.value) || 0)
}
className="text-sm"
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="mt-0.5 hover:bg-red-100 hover:text-red-700"
className="mt-0.5 hover:bg-red-100 hover:text-red-700 h-9 w-9 p-0"
onClick={() => handleRemoveExpense(item.id)}
>
<X className="w-4 h-4" />
@ -386,15 +391,15 @@ export function DealerCompletionDocumentsModal({
</div>
))}
{expenseItems.length === 0 && (
<p className="text-sm text-gray-500 italic">
<p className="text-xs sm:text-sm text-gray-500 italic">
No expenses added. Click "Add Expense" to add expense items.
</p>
)}
{expenseItems.length > 0 && totalClosedExpenses > 0 && (
<div className="pt-2 border-t">
<div className="flex justify-between items-center">
<span className="font-semibold">Total Closed Expenses:</span>
<span className="font-semibold text-lg">
<span className="font-semibold text-sm sm:text-base">Total Closed Expenses:</span>
<span className="font-semibold text-base sm:text-lg">
{totalClosedExpenses.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
@ -404,28 +409,30 @@ export function DealerCompletionDocumentsModal({
</div>
{/* Completion Evidence Section */}
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Completion Evidence</h3>
<h3 className="font-semibold text-base sm:text-lg">Completion Evidence</h3>
<Badge className="bg-destructive text-white text-xs">Required</Badge>
</div>
{/* Completion Documents */}
<div>
<Label className="text-base font-semibold flex items-center gap-2">
<FileText className="w-4 h-4" />
Completion Documents *
</Label>
<p className="text-sm text-gray-600 mb-2">
Upload documents proving activity completion (reports, certificates, etc.) - Can upload multiple files or ZIP folder
</p>
<div
className={`border-2 border-dashed rounded-lg p-4 transition-all duration-200 ${
completionDocuments.length > 0
? 'border-green-500 bg-green-50 hover:border-green-600'
: 'border-gray-300 hover:border-blue-500 bg-white'
}`}
>
{/* Grid layout for Completion Documents and Activity Photos */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
{/* Completion Documents */}
<div>
<Label className="text-sm sm:text-base font-semibold flex items-center gap-2">
<FileText className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
Completion Documents *
</Label>
<p className="text-xs sm:text-sm text-gray-600 mb-2">
Upload documents proving activity completion (reports, certificates, etc.) - Can upload multiple files or ZIP folder
</p>
<div
className={`border-2 border-dashed rounded-lg p-3 sm:p-4 transition-all duration-200 ${
completionDocuments.length > 0
? 'border-green-500 bg-green-50 hover:border-green-600'
: 'border-gray-300 hover:border-blue-500 bg-white'
}`}
>
<input
ref={completionDocsInputRef}
type="file"
@ -469,10 +476,10 @@ export function DealerCompletionDocumentsModal({
{completionDocuments.map((file, index) => (
<div
key={index}
className="flex items-start justify-between bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 p-3 rounded-lg text-sm shadow-sm hover:shadow-md transition-shadow w-full"
className="flex items-start justify-between bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 p-2 sm:p-3 rounded-lg text-xs sm:text-sm shadow-sm hover:shadow-md transition-shadow w-full"
>
<div className="flex items-start gap-2 flex-1 min-w-0 pr-2">
<FileText className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<FileText className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-green-600 flex-shrink-0 mt-0.5" />
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
@ -506,31 +513,31 @@ export function DealerCompletionDocumentsModal({
onClick={() => handleRemoveCompletionDoc(index)}
title="Remove document"
>
<X className="w-4 h-4" />
<X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Activity Photos */}
<div>
<Label className="text-base font-semibold flex items-center gap-2">
<Image className="w-4 h-4" />
Activity Photos *
</Label>
<p className="text-sm text-gray-600 mb-2">
Upload photos from the completed activity (event photos, installations, etc.)
</p>
<div
className={`border-2 border-dashed rounded-lg p-4 transition-all duration-200 ${
activityPhotos.length > 0
? 'border-green-500 bg-green-50 hover:border-green-600'
: 'border-gray-300 hover:border-blue-500 bg-white'
}`}
>
{/* Activity Photos */}
<div>
<Label className="text-sm sm:text-base font-semibold flex items-center gap-2">
<Image className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
Activity Photos *
</Label>
<p className="text-xs sm:text-sm text-gray-600 mb-2">
Upload photos from the completed activity (event photos, installations, etc.)
</p>
<div
className={`border-2 border-dashed rounded-lg p-3 sm:p-4 transition-all duration-200 ${
activityPhotos.length > 0
? 'border-green-500 bg-green-50 hover:border-green-600'
: 'border-gray-300 hover:border-blue-500 bg-white'
}`}
>
<input
ref={photosInputRef}
type="file"
@ -574,10 +581,10 @@ export function DealerCompletionDocumentsModal({
{activityPhotos.map((file, index) => (
<div
key={index}
className="flex items-start justify-between bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 p-3 rounded-lg text-sm shadow-sm hover:shadow-md transition-shadow w-full"
className="flex items-start justify-between bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 p-2 sm:p-3 rounded-lg text-xs sm:text-sm shadow-sm hover:shadow-md transition-shadow w-full"
>
<div className="flex items-start gap-2 flex-1 min-w-0 pr-2">
<Image className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<Image className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-green-600 flex-shrink-0 mt-0.5" />
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
@ -611,39 +618,42 @@ export function DealerCompletionDocumentsModal({
onClick={() => handleRemovePhoto(index)}
title="Remove photo"
>
<X className="w-4 h-4" />
<X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* Supporting Documents Section */}
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Supporting Documents</h3>
<h3 className="font-semibold text-base sm:text-lg">Supporting Documents</h3>
<Badge className="bg-secondary text-secondary-foreground text-xs">Optional</Badge>
</div>
{/* Invoices/Receipts */}
<div>
<Label className="text-base font-semibold flex items-center gap-2">
<Receipt className="w-4 h-4" />
Invoices / Receipts
</Label>
<p className="text-sm text-gray-600 mb-2">
Upload invoices and receipts for expenses incurred
</p>
<div
className={`border-2 border-dashed rounded-lg p-4 transition-all duration-200 ${
invoicesReceipts.length > 0
? 'border-blue-500 bg-blue-50 hover:border-blue-600'
: 'border-gray-300 hover:border-blue-500 bg-white'
}`}
>
{/* Grid layout for Invoices/Receipts and Attendance Sheet */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
{/* Invoices/Receipts */}
<div>
<Label className="text-sm sm:text-base font-semibold flex items-center gap-2">
<Receipt className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
Invoices / Receipts
</Label>
<p className="text-xs sm:text-sm text-gray-600 mb-2">
Upload invoices and receipts for expenses incurred
</p>
<div
className={`border-2 border-dashed rounded-lg p-3 sm:p-4 transition-all duration-200 ${
invoicesReceipts.length > 0
? 'border-blue-500 bg-blue-50 hover:border-blue-600'
: 'border-gray-300 hover:border-blue-500 bg-white'
}`}
>
<input
ref={invoicesInputRef}
type="file"
@ -687,10 +697,10 @@ export function DealerCompletionDocumentsModal({
{invoicesReceipts.map((file, index) => (
<div
key={index}
className="flex items-start justify-between bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 p-3 rounded-lg text-sm shadow-sm hover:shadow-md transition-shadow w-full"
className="flex items-start justify-between bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 p-2 sm:p-3 rounded-lg text-xs sm:text-sm shadow-sm hover:shadow-md transition-shadow w-full"
>
<div className="flex items-start gap-2 flex-1 min-w-0 pr-2">
<Receipt className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
<Receipt className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-blue-600 flex-shrink-0 mt-0.5" />
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
@ -724,30 +734,30 @@ export function DealerCompletionDocumentsModal({
onClick={() => handleRemoveInvoice(index)}
title="Remove document"
>
<X className="w-4 h-4" />
<X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Attendance Sheet */}
<div>
<Label className="text-base font-semibold">
Attendance Sheet / Participant List
</Label>
<p className="text-sm text-gray-600 mb-2">
Upload attendance records or participant lists (if applicable)
</p>
<div
className={`border-2 border-dashed rounded-lg p-4 transition-all duration-200 ${
attendanceSheet
? 'border-blue-500 bg-blue-50 hover:border-blue-600'
: 'border-gray-300 hover:border-blue-500 bg-white'
}`}
>
{/* Attendance Sheet */}
<div>
<Label className="text-sm sm:text-base font-semibold">
Attendance Sheet / Participant List
</Label>
<p className="text-xs sm:text-sm text-gray-600 mb-2">
Upload attendance records or participant lists (if applicable)
</p>
<div
className={`border-2 border-dashed rounded-lg p-3 sm:p-4 transition-all duration-200 ${
attendanceSheet
? 'border-blue-500 bg-blue-50 hover:border-blue-600'
: 'border-gray-300 hover:border-blue-500 bg-white'
}`}
>
<input
ref={attendanceInputRef}
type="file"
@ -787,9 +797,9 @@ export function DealerCompletionDocumentsModal({
<p className="text-xs font-medium text-gray-600 mb-2">
Selected Document:
</p>
<div className="flex items-start justify-between bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 p-3 rounded-lg text-sm shadow-sm hover:shadow-md transition-shadow w-full">
<div className="flex items-start justify-between bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 p-2 sm:p-3 rounded-lg text-xs sm:text-sm shadow-sm hover:shadow-md transition-shadow w-full">
<div className="flex items-start gap-2 flex-1 min-w-0 pr-2">
<FileText className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
<FileText className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-blue-600 flex-shrink-0 mt-0.5" />
<span className="text-gray-800 font-medium break-words break-all">{attendanceSheet.name}</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
@ -826,18 +836,19 @@ export function DealerCompletionDocumentsModal({
}}
title="Remove document"
>
<X className="w-4 h-4" />
<X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
</Button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Completion Description */}
<div className="space-y-2">
<Label className="text-base font-semibold flex items-center gap-2" htmlFor="completionDescription">
<div className="space-y-1.5 sm:space-y-2 max-w-3xl">
<Label className="text-sm sm:text-base font-semibold flex items-center gap-2" htmlFor="completionDescription">
Brief Description of Completion *
</Label>
<Textarea
@ -845,16 +856,16 @@ export function DealerCompletionDocumentsModal({
placeholder="Provide a brief description of the completed activity, including key highlights, outcomes, challenges faced, and any relevant observations..."
value={completionDescription}
onChange={(e) => setCompletionDescription(e.target.value)}
className="min-h-[120px]"
className="min-h-[100px] sm:min-h-[120px] text-sm"
/>
<p className="text-xs text-gray-500">{completionDescription.length} characters</p>
</div>
{/* Warning Message */}
{!isFormValid && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
<CircleAlert className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-800">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 sm:p-4 flex items-start gap-2 sm:gap-3">
<CircleAlert className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-xs sm:text-sm text-amber-800">
<p className="font-semibold mb-1">Missing Required Information</p>
<p>
Please ensure completion date, at least one document/photo, and description are provided before submitting.
@ -862,9 +873,10 @@ export function DealerCompletionDocumentsModal({
</div>
</div>
)}
</div>
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 px-6 pt-3 pb-6 border-t flex-shrink-0">
<Button
variant="outline"
onClick={handleClose}

View File

@ -0,0 +1,37 @@
.dealer-proposal-modal {
width: 90vw !important;
max-width: 90vw !important;
max-height: 95vh !important;
}
/* Mobile responsive */
@media (max-width: 640px) {
.dealer-proposal-modal {
width: 95vw !important;
max-width: 95vw !important;
max-height: 95vh !important;
}
}
/* Large screens - use more width */
@media (min-width: 1024px) {
.dealer-proposal-modal {
width: 85vw !important;
max-width: 85vw !important;
}
}
@media (min-width: 1280px) {
.dealer-proposal-modal {
width: 80vw !important;
max-width: 80vw !important;
}
}
@media (min-width: 1536px) {
.dealer-proposal-modal {
width: 75vw !important;
max-width: 75vw !important;
}
}

View File

@ -21,6 +21,7 @@ import { Badge } from '@/components/ui/badge';
import { Upload, Plus, X, Calendar, DollarSign, CircleAlert, CheckCircle2, FileText, Eye, Download } from 'lucide-react';
import { toast } from 'sonner';
import '@/components/common/FilePreview/FilePreview.css';
import './DealerProposalModal.css';
interface CostItem {
id: string;
@ -246,44 +247,47 @@ export function DealerProposalSubmissionModal({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto overflow-x-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-2xl">
<Upload className="w-6 h-6 text-[--re-green]" />
<DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col">
<DialogHeader className="flex-shrink-0 pb-3 lg:pb-4">
<DialogTitle className="flex items-center gap-2 text-xl lg:text-2xl">
<Upload className="w-5 h-5 lg:w-6 lg:h-6 text-[--re-green]" />
Dealer Proposal Submission
</DialogTitle>
<DialogDescription className="text-base">
<DialogDescription className="text-sm lg:text-base">
Step 1: Upload proposal and planning details
</DialogDescription>
<div className="space-y-1 mt-2 text-sm text-gray-600">
<div>
<strong>Dealer:</strong> {dealerName}
<div className="space-y-1 mt-2 text-xs lg:text-sm text-gray-600">
<div className="flex flex-wrap gap-x-4 gap-y-1">
<div>
<strong>Dealer:</strong> {dealerName}
</div>
<div>
<strong>Activity:</strong> {activityName}
</div>
</div>
<div>
<strong>Activity:</strong> {activityName}
</div>
<div className="mt-2">
<div className="mt-1">
Please upload proposal document, provide cost breakdown, timeline, and detailed comments.
</div>
</div>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3 lg:py-4">
<div className="space-y-4 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6">
{/* Proposal Document Section */}
<div className="space-y-4">
<div className="space-y-3 lg:space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Proposal Document</h3>
<h3 className="font-semibold text-base lg:text-lg">Proposal Document</h3>
<Badge className="bg-red-500 text-white text-xs">Required</Badge>
</div>
<div>
<Label className="text-base font-semibold flex items-center gap-2">
<Label className="text-sm lg:text-base font-semibold flex items-center gap-2">
Proposal Document *
</Label>
<p className="text-sm text-gray-600 mb-2">
<p className="text-xs lg:text-sm text-gray-600 mb-2">
Detailed proposal with activity details and requested information
</p>
<div
className={`border-2 border-dashed rounded-lg p-4 transition-all duration-200 ${
className={`border-2 border-dashed rounded-lg p-3 lg:p-4 transition-all duration-200 ${
proposalDocument
? 'border-green-500 bg-green-50 hover:border-green-600'
: 'border-gray-300 hover:border-blue-500 bg-white'
@ -351,10 +355,10 @@ export function DealerProposalSubmissionModal({
</div>
{/* Cost Breakup Section */}
<div className="space-y-4">
<div className="space-y-3 lg:space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Cost Breakup</h3>
<h3 className="font-semibold text-base lg:text-lg">Cost Breakup</h3>
<Badge className="bg-red-500 text-white text-xs">Required</Badge>
</div>
<Button
@ -363,11 +367,12 @@ export function DealerProposalSubmissionModal({
className="bg-[#2d4a3e] hover:bg-[#1f3329] text-white"
size="sm"
>
<Plus className="w-4 h-4 mr-1" />
Add Item
<Plus className="w-3 h-3 lg:w-4 lg:h-4 mr-1" />
<span className="hidden sm:inline">Add Item</span>
<span className="sm:hidden">Add</span>
</Button>
</div>
<div className="space-y-3">
<div className="space-y-2 lg:space-y-2 max-h-[200px] lg:max-h-[180px] overflow-y-auto">
{costItems.map((item) => (
<div key={item.id} className="flex gap-2 items-start">
<div className="flex-1">
@ -404,13 +409,13 @@ export function DealerProposalSubmissionModal({
</div>
))}
</div>
<div className="border-2 border-gray-300 rounded-lg p-4 bg-white">
<div className="border-2 border-gray-300 rounded-lg p-3 lg:p-4 bg-white">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<DollarSign className="w-5 h-5 text-gray-700" />
<span className="font-semibold text-gray-900">Estimated Budget</span>
<DollarSign className="w-4 h-4 lg:w-5 lg:h-5 text-gray-700" />
<span className="font-semibold text-sm lg:text-base text-gray-900">Estimated Budget</span>
</div>
<div className="text-2xl font-bold text-gray-900">
<div className="text-xl lg:text-2xl font-bold text-gray-900">
{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
@ -418,12 +423,12 @@ export function DealerProposalSubmissionModal({
</div>
{/* Timeline for Closure Section */}
<div className="space-y-4">
<div className="space-y-3 lg:space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Timeline for Closure</h3>
<h3 className="font-semibold text-base lg:text-lg">Timeline for Closure</h3>
<Badge className="bg-red-500 text-white text-xs">Required</Badge>
</div>
<div className="space-y-3">
<div className="space-y-2 lg:space-y-2">
<div className="flex gap-2">
<Button
type="button"
@ -453,7 +458,7 @@ export function DealerProposalSubmissionModal({
</div>
{timelineMode === 'date' ? (
<div>
<Label className="text-sm font-medium mb-2 block">
<Label className="text-xs lg:text-sm font-medium mb-1.5 lg:mb-2 block">
Expected Completion Date
</Label>
<Input
@ -461,11 +466,12 @@ export function DealerProposalSubmissionModal({
min={minDate}
value={expectedCompletionDate}
onChange={(e) => setExpectedCompletionDate(e.target.value)}
className="h-9 lg:h-10 w-full max-w-xs"
/>
</div>
) : (
<div>
<Label className="text-sm font-medium mb-2 block">
<Label className="text-xs lg:text-sm font-medium mb-1.5 lg:mb-2 block">
Number of Days
</Label>
<Input
@ -474,6 +480,7 @@ export function DealerProposalSubmissionModal({
min="1"
value={numberOfDays}
onChange={(e) => setNumberOfDays(e.target.value)}
className="h-9 lg:h-10 w-full max-w-xs"
/>
</div>
)}
@ -481,20 +488,20 @@ export function DealerProposalSubmissionModal({
</div>
{/* Other Supporting Documents Section */}
<div className="space-y-4">
<div className="space-y-3 lg:space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Other Supporting Documents</h3>
<h3 className="font-semibold text-base lg:text-lg">Other Supporting Documents</h3>
<Badge variant="secondary" className="text-xs">Optional</Badge>
</div>
<div>
<Label className="flex items-center gap-2 text-base font-semibold">
<Label className="flex items-center gap-2 text-sm lg:text-base font-semibold">
Additional Documents
</Label>
<p className="text-sm text-gray-600 mb-2">
<p className="text-xs lg:text-sm text-gray-600 mb-2">
Any other supporting documents (invoices, receipts, photos, etc.)
</p>
<div
className={`border-2 border-dashed rounded-lg p-4 transition-all duration-200 ${
className={`border-2 border-dashed rounded-lg p-3 lg:p-4 transition-all duration-200 ${
otherDocuments.length > 0
? 'border-blue-500 bg-blue-50 hover:border-blue-600'
: 'border-gray-300 hover:border-blue-500 bg-white'
@ -535,14 +542,14 @@ export function DealerProposalSubmissionModal({
</label>
</div>
{otherDocuments.length > 0 && (
<div className="mt-3 space-y-2">
<div className="mt-2 lg:mt-3 space-y-2 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
<p className="text-xs font-medium text-gray-600 mb-1">
Selected Documents ({otherDocuments.length}):
</p>
{otherDocuments.map((file, index) => (
<div
key={index}
className="flex items-start justify-between bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 p-3 rounded-lg text-sm shadow-sm hover:shadow-md transition-shadow w-full"
className="flex items-start justify-between bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 p-2 lg:p-3 rounded-lg text-xs lg:text-sm shadow-sm hover:shadow-md transition-shadow w-full"
>
<div className="flex items-start gap-2 flex-1 min-w-0 pr-2">
<FileText className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
@ -593,7 +600,7 @@ export function DealerProposalSubmissionModal({
{/* Dealer Comments Section */}
<div className="space-y-2">
<Label htmlFor="dealerComments" className="text-base font-semibold flex items-center gap-2">
<Label htmlFor="dealerComments" className="text-sm lg:text-base font-semibold flex items-center gap-2">
Dealer Comments / Details *
</Label>
<Textarea
@ -601,16 +608,16 @@ export function DealerProposalSubmissionModal({
placeholder="Provide detailed comments about this claim request, including any special considerations, execution details, or additional information..."
value={dealerComments}
onChange={(e) => setDealerComments(e.target.value)}
className="min-h-[120px]"
className="min-h-[80px] lg:min-h-[100px] text-sm"
/>
<p className="text-xs text-gray-500">{dealerComments.length} characters</p>
</div>
{/* Warning Message */}
{!isFormValid && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
<CircleAlert className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-800">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 lg:p-4 flex items-start gap-2 lg:gap-3 lg:col-span-2">
<CircleAlert className="w-4 h-4 lg:w-5 lg:h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-xs lg:text-sm text-amber-800">
<p className="font-semibold mb-1">Missing Required Information</p>
<p>
Please ensure proposal document, cost breakup, timeline, and dealer comments are provided before submitting.
@ -618,9 +625,10 @@ export function DealerProposalSubmissionModal({
</div>
</div>
)}
</div>
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end flex-shrink-0 pt-3 lg:pt-4 border-t">
<Button
variant="outline"
onClick={handleClose}

View File

@ -0,0 +1,37 @@
.dept-lead-io-modal {
width: 90vw !important;
max-width: 90vw !important;
max-height: 95vh !important;
}
/* Mobile responsive */
@media (max-width: 640px) {
.dept-lead-io-modal {
width: 95vw !important;
max-width: 95vw !important;
max-height: 95vh !important;
}
}
/* Large screens - use more width */
@media (min-width: 1024px) {
.dept-lead-io-modal {
width: 85vw !important;
max-width: 85vw !important;
}
}
@media (min-width: 1280px) {
.dept-lead-io-modal {
width: 80vw !important;
max-width: 80vw !important;
}
}
@media (min-width: 1536px) {
.dept-lead-io-modal {
width: 75vw !important;
max-width: 75vw !important;
}
}

View File

@ -21,6 +21,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { CircleCheckBig, CircleX, Receipt, TriangleAlert } from 'lucide-react';
import { toast } from 'sonner';
import './DeptLeadIOApprovalModal.css';
interface DeptLeadIOApprovalModalProps {
isOpen: boolean;
@ -145,35 +146,35 @@ export function DeptLeadIOApprovalModal({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-green-100">
<CircleCheckBig className="w-6 h-6 text-green-600" />
<DialogContent className="dept-lead-io-modal overflow-hidden flex flex-col">
<DialogHeader className="flex-shrink-0 px-6 pt-6 pb-3">
<div className="flex items-center gap-2 lg:gap-3 mb-2">
<div className="p-1.5 lg:p-2 rounded-lg bg-green-100">
<CircleCheckBig className="w-5 h-5 lg:w-6 lg:h-6 text-green-600" />
</div>
<div className="flex-1">
<DialogTitle className="font-semibold text-xl">
<DialogTitle className="font-semibold text-lg lg:text-xl">
Approve and Organise IO
</DialogTitle>
<DialogDescription className="text-sm mt-1">
<DialogDescription className="text-xs lg:text-sm mt-1">
Review IO details and provide your approval comments
</DialogDescription>
</div>
</div>
{/* Request Info Card */}
<div className="space-y-3 p-4 bg-gray-50 rounded-lg border">
<div className="space-y-2 lg:space-y-3 p-3 lg:p-4 bg-gray-50 rounded-lg border">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">Workflow Step:</span>
<Badge variant="outline" className="font-mono">Step 3</Badge>
<span className="font-medium text-sm lg:text-base text-gray-900">Workflow Step:</span>
<Badge variant="outline" className="font-mono text-xs">Step 3</Badge>
</div>
<div>
<span className="font-medium text-gray-900">Title:</span>
<p className="text-gray-700 mt-1">{requestTitle || '—'}</p>
<span className="font-medium text-sm lg:text-base text-gray-900">Title:</span>
<p className="text-xs lg:text-sm text-gray-700 mt-1">{requestTitle || '—'}</p>
</div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">Action:</span>
<Badge className="bg-green-100 text-green-800 border-green-200">
<span className="font-medium text-sm lg:text-base text-gray-900">Action:</span>
<Badge className="bg-green-100 text-green-800 border-green-200 text-xs">
<CircleCheckBig className="w-3 h-3 mr-1" />
APPROVE
</Badge>
@ -181,172 +182,176 @@ export function DeptLeadIOApprovalModal({
</div>
</DialogHeader>
<div className="space-y-3">
{/* Action Toggle Buttons */}
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
<Button
type="button"
onClick={() => setActionType('approve')}
className={`flex-1 ${
actionType === 'approve'
? 'bg-green-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200'
}`}
variant={actionType === 'approve' ? 'default' : 'ghost'}
>
<CircleCheckBig className="w-4 h-4 mr-1" />
Approve
</Button>
<Button
type="button"
onClick={() => setActionType('reject')}
className={`flex-1 ${
actionType === 'reject'
? 'bg-red-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200'
}`}
variant={actionType === 'reject' ? 'destructive' : 'ghost'}
>
<CircleX className="w-4 h-4 mr-1" />
Reject
</Button>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3 lg:py-4 px-6">
<div className="space-y-3 lg:space-y-4">
{/* Action Toggle Buttons */}
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
<Button
type="button"
onClick={() => setActionType('approve')}
className={`flex-1 text-sm lg:text-base ${
actionType === 'approve'
? 'bg-green-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200'
}`}
variant={actionType === 'approve' ? 'default' : 'ghost'}
>
<CircleCheckBig className="w-4 h-4 mr-1" />
Approve
</Button>
<Button
type="button"
onClick={() => setActionType('reject')}
className={`flex-1 text-sm lg:text-base ${
actionType === 'reject'
? 'bg-red-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200'
}`}
variant={actionType === 'reject' ? 'destructive' : 'ghost'}
>
<CircleX className="w-4 h-4 mr-1" />
Reject
</Button>
</div>
{/* IO Organisation Details - Only shown when approving */}
{actionType === 'approve' && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg space-y-3">
<div className="flex items-center gap-2">
<Receipt className="w-4 h-4 text-blue-600" />
<h4 className="font-semibold text-blue-900">IO Organisation Details</h4>
</div>
{/* IO Number - Read-only from IO table */}
<div className="space-y-1">
<Label htmlFor="ioNumber" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
IO Number <span className="text-red-500">*</span>
</Label>
<Input
id="ioNumber"
value={ioNumber || '—'}
disabled
readOnly
className="bg-gray-100 h-8 cursor-not-allowed"
/>
{!ioNumber && (
<p className="text-xs text-red-600 mt-1">
IO number not found. Please block amount from IO tab first.
</p>
)}
{ioNumber && (
<p className="text-xs text-blue-600 mt-1">
Loaded from IO table
</p>
)}
</div>
{/* Main Content Area - Two Column Layout on Large Screens */}
<div className="space-y-3 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6">
{/* Left Column - IO Organisation Details (Only shown when approving) */}
{actionType === 'approve' && (
<div className="p-3 lg:p-4 bg-blue-50 border border-blue-200 rounded-lg space-y-3">
<div className="flex items-center gap-2">
<Receipt className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
<h4 className="font-semibold text-sm lg:text-base text-blue-900">IO Organisation Details</h4>
</div>
{/* IO Number - Read-only from IO table */}
<div className="space-y-1">
<Label htmlFor="ioNumber" className="text-xs lg:text-sm font-semibold text-gray-900 flex items-center gap-2">
IO Number <span className="text-red-500">*</span>
</Label>
<Input
id="ioNumber"
value={ioNumber || '—'}
disabled
readOnly
className="bg-gray-100 h-8 lg:h-9 cursor-not-allowed text-xs lg:text-sm"
/>
{!ioNumber && (
<p className="text-xs text-red-600 mt-1">
IO number not found. Please block amount from IO tab first.
</p>
)}
{ioNumber && (
<p className="text-xs text-blue-600 mt-1">
Loaded from IO table
</p>
)}
</div>
{/* IO Balance Information - Read-only */}
<div className="grid grid-cols-2 gap-2">
{/* Blocked Amount Display */}
{preFilledBlockedAmount !== undefined && preFilledBlockedAmount > 0 && (
<div className="p-2 bg-green-50 border border-green-200 rounded">
<div className="flex flex-col">
<span className="text-xs font-semibold text-gray-700">Blocked Amount:</span>
<span className="text-sm font-bold text-green-700 mt-1">
{preFilledBlockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
{/* IO Balance Information - Read-only */}
<div className="grid grid-cols-2 gap-2">
{/* Blocked Amount Display */}
{preFilledBlockedAmount !== undefined && preFilledBlockedAmount > 0 && (
<div className="p-2 bg-green-50 border border-green-200 rounded">
<div className="flex flex-col">
<span className="text-xs font-semibold text-gray-700">Blocked Amount:</span>
<span className="text-xs lg:text-sm font-bold text-green-700 mt-1">
{preFilledBlockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
</div>
)}
{/* Remaining Balance Display */}
{preFilledRemainingBalance !== undefined && preFilledRemainingBalance !== null && (
<div className="p-2 bg-blue-50 border border-blue-200 rounded">
<div className="flex flex-col">
<span className="text-xs font-semibold text-gray-700">Remaining Balance:</span>
<span className="text-xs lg:text-sm font-bold text-blue-700 mt-1">
{preFilledRemainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
</div>
)}
</div>
{/* IO Remark - Read-only field (prefilled from IO tab, cannot be modified) */}
<div className="space-y-1">
<Label htmlFor="ioRemark" className="text-xs lg:text-sm font-semibold text-gray-900 flex items-center gap-2">
IO Remark <span className="text-red-500">*</span>
</Label>
<Textarea
id="ioRemark"
placeholder="Enter remarks about IO organization"
value={ioRemark || '—'}
disabled
readOnly
rows={3}
className="bg-gray-100 text-xs lg:text-sm min-h-[80px] lg:min-h-[100px] resize-none cursor-not-allowed"
/>
<div className="flex items-center justify-between text-xs">
{preFilledIORemark ? (
<span className="text-blue-600">
Loaded from IO tab
</span>
) : (
<span className="text-red-600">
IO remark not found. Please add remark in IO tab first.
</span>
)}
<span className="text-gray-600">{ioRemarkChars}/{maxIoRemarkChars}</span>
</div>
</div>
)}
</div>
)}
{/* Remaining Balance Display */}
{preFilledRemainingBalance !== undefined && preFilledRemainingBalance !== null && (
<div className="p-2 bg-blue-50 border border-blue-200 rounded">
<div className="flex flex-col">
<span className="text-xs font-semibold text-gray-700">Remaining Balance:</span>
<span className="text-sm font-bold text-blue-700 mt-1">
{preFilledRemainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
</div>
)}
</div>
{/* IO Remark - Editable field (prefilled from IO tab, but can be modified) */}
<div className="space-y-1">
<Label htmlFor="ioRemark" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
IO Remark <span className="text-red-500">*</span>
{/* Right Column - Comments & Remarks */}
<div className={`space-y-1.5 ${actionType === 'approve' ? '' : 'lg:col-span-2'}`}>
<Label htmlFor="comment" className="text-xs lg:text-sm font-semibold text-gray-900 flex items-center gap-2">
Comments & Remarks <span className="text-red-500">*</span>
</Label>
<Textarea
id="ioRemark"
placeholder="Enter remarks about IO organization"
value={ioRemark}
id="comment"
placeholder={
actionType === 'approve'
? 'Enter your approval comments and any conditions or notes...'
: 'Enter detailed reasons for rejection...'
}
value={comments}
onChange={(e) => {
const value = e.target.value;
if (value.length <= maxIoRemarkChars) {
setIoRemark(value);
if (value.length <= maxCommentsChars) {
setComments(value);
}
}}
rows={3}
className="bg-white text-sm min-h-[80px] resize-none"
disabled={false}
readOnly={false}
rows={4}
className="text-xs lg:text-sm min-h-[80px] lg:min-h-[100px] resize-none"
/>
<div className="flex items-center justify-between text-xs">
{preFilledIORemark && (
<span className="text-blue-600">
Prefilled from IO tab (editable)
</span>
)}
<span className="text-gray-600">{ioRemarkChars}/{maxIoRemarkChars}</span>
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center gap-1">
<TriangleAlert className="w-3 h-3" />
Required and visible to all
</div>
<span>{commentsChars}/{maxCommentsChars}</span>
</div>
</div>
</div>
)}
{/* Comments & Remarks */}
<div className="space-y-1.5">
<Label htmlFor="comment" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
Comments & Remarks <span className="text-red-500">*</span>
</Label>
<Textarea
id="comment"
placeholder={
actionType === 'approve'
? 'Enter your approval comments and any conditions or notes...'
: 'Enter detailed reasons for rejection...'
}
value={comments}
onChange={(e) => {
const value = e.target.value;
if (value.length <= maxCommentsChars) {
setComments(value);
}
}}
rows={4}
className="text-sm min-h-[80px] resize-none"
/>
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center gap-1">
<TriangleAlert className="w-3 h-3" />
Required and visible to all
</div>
<span>{commentsChars}/{maxCommentsChars}</span>
</div>
</div>
</div>
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<DialogFooter className="flex-shrink-0 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 px-6 pb-6 pt-3 border-t">
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
className="text-sm lg:text-base"
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!isFormValid || submitting}
className={`${
className={`text-sm lg:text-base ${
actionType === 'approve'
? 'bg-green-600 hover:bg-green-700'
: 'bg-red-600 hover:bg-red-700'

View File

@ -30,6 +30,7 @@ import {
import { toast } from 'sonner';
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
import '@/components/common/FilePreview/FilePreview.css';
import './DealerProposalModal.css';
interface CostItem {
id: string;
@ -264,43 +265,46 @@ export function InitiatorProposalApprovalModal({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4 flex-shrink-0 border-b">
<DialogTitle className="flex items-center gap-2 text-2xl">
<CheckCircle className="w-6 h-6 text-green-600" />
<DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col">
<DialogHeader className="flex-shrink-0 pb-3 lg:pb-4 px-6 pt-4 lg:pt-6 border-b">
<DialogTitle className="flex items-center gap-2 text-lg lg:text-xl">
<CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-green-600" />
Requestor Evaluation & Confirmation
</DialogTitle>
<DialogDescription className="text-base">
<DialogDescription className="text-xs lg:text-sm">
Step 2: Review dealer proposal and make a decision
</DialogDescription>
<div className="space-y-1 mt-2 text-sm text-gray-600">
<div>
<strong>Dealer:</strong> {dealerName}
<div className="space-y-1 mt-2 text-xs text-gray-600">
<div className="flex flex-wrap gap-x-4 gap-y-1">
<div>
<strong>Dealer:</strong> {dealerName}
</div>
<div>
<strong>Activity:</strong> {activityName}
</div>
</div>
<div>
<strong>Activity:</strong> {activityName}
</div>
<div className="mt-2 text-amber-600 font-medium">
<div className="mt-1 text-amber-600 font-medium">
Decision: <strong>Confirms?</strong> (YES Continue to Dept Lead / NO Request is cancelled)
</div>
</div>
</DialogHeader>
<div className="space-y-6 py-4 px-6 overflow-y-auto flex-1">
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3 lg:py-4 px-6">
<div className="space-y-4 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6">
{/* Proposal Document Section */}
<div className="space-y-3">
<div className="space-y-2 lg:space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-600" />
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-blue-600" />
Proposal Document
</h3>
</div>
{proposalData?.proposalDocument ? (
<div className="border rounded-lg p-4 bg-gray-50 flex items-center justify-between">
<div className="flex items-center gap-3">
<FileText className="w-8 h-8 text-blue-600" />
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between">
<div className="flex items-center gap-2 lg:gap-3">
<FileText className="w-5 h-5 lg:w-6 lg:h-6 text-blue-600" />
<div>
<p className="font-medium text-gray-900">{proposalData.proposalDocument.name}</p>
<p className="font-medium text-xs lg:text-sm text-gray-900">{proposalData.proposalDocument.name}</p>
{proposalData?.submittedAt && (
<p className="text-xs text-gray-500">
Submitted on {formatDate(proposalData.submittedAt)}
@ -348,15 +352,15 @@ export function InitiatorProposalApprovalModal({
</div>
</div>
) : (
<p className="text-sm text-gray-500 italic">No proposal document available</p>
<p className="text-xs text-gray-500 italic">No proposal document available</p>
)}
</div>
{/* Cost Breakup Section */}
<div className="space-y-3">
<div className="space-y-2 lg:space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg flex items-center gap-2">
<DollarSign className="w-5 h-5 text-green-600" />
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<DollarSign className="w-4 h-4 text-green-600" />
Cost Breakup
</h3>
</div>
@ -372,52 +376,52 @@ export function InitiatorProposalApprovalModal({
return costBreakup && Array.isArray(costBreakup) && costBreakup.length > 0 ? (
<>
<div className="border rounded-lg overflow-hidden">
<div className="bg-gray-50 px-4 py-2 border-b">
<div className="grid grid-cols-2 gap-4 text-sm font-semibold text-gray-700">
<div className="border rounded-lg overflow-hidden max-h-[200px] lg:max-h-[180px] overflow-y-auto">
<div className="bg-gray-50 px-3 lg:px-4 py-2 border-b sticky top-0">
<div className="grid grid-cols-2 gap-4 text-xs lg:text-sm font-semibold text-gray-700">
<div>Item Description</div>
<div className="text-right">Amount</div>
</div>
</div>
<div className="divide-y">
{costBreakup.map((item: any, index: number) => (
<div key={item?.id || item?.description || index} className="px-4 py-3 grid grid-cols-2 gap-4">
<div className="text-sm text-gray-700">{item?.description || 'N/A'}</div>
<div className="text-sm font-semibold text-gray-900 text-right">
<div key={item?.id || item?.description || index} className="px-3 lg:px-4 py-2 lg:py-3 grid grid-cols-2 gap-4">
<div className="text-xs lg:text-sm text-gray-700">{item?.description || 'N/A'}</div>
<div className="text-xs lg:text-sm font-semibold text-gray-900 text-right">
{(Number(item?.amount) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
))}
</div>
</div>
<div className="border-2 border-[--re-green] rounded-lg p-4">
<div className="border-2 border-[--re-green] rounded-lg p-2.5 lg:p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<DollarSign className="w-5 h-5 text-[--re-green]" />
<span className="font-semibold text-gray-700">Total Estimated Budget</span>
<DollarSign className="w-4 h-4 text-[--re-green]" />
<span className="font-semibold text-xs lg:text-sm text-gray-700">Total Estimated Budget</span>
</div>
<div className="text-2xl font-bold text-[--re-green]">
<div className="text-lg lg:text-xl font-bold text-[--re-green]">
{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
</div>
</>
) : (
<p className="text-sm text-gray-500 italic">No cost breakdown available</p>
<p className="text-xs text-gray-500 italic">No cost breakdown available</p>
);
})()}
</div>
{/* Timeline Section */}
<div className="space-y-3">
<div className="space-y-2 lg:space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg flex items-center gap-2">
<Calendar className="w-5 h-5 text-purple-600" />
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<Calendar className="w-4 h-4 text-purple-600" />
Expected Completion Date
</h3>
</div>
<div className="border rounded-lg p-4 bg-gray-50">
<p className="text-lg font-semibold text-gray-900">
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50">
<p className="text-sm lg:text-base font-semibold text-gray-900">
{proposalData?.expectedCompletionDate ? formatDate(proposalData.expectedCompletionDate) : 'Not specified'}
</p>
</div>
@ -425,25 +429,25 @@ export function InitiatorProposalApprovalModal({
{/* Other Supporting Documents */}
{proposalData?.otherDocuments && proposalData.otherDocuments.length > 0 && (
<div className="space-y-3">
<div className="space-y-2 lg:space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg flex items-center gap-2">
<FileText className="w-5 h-5 text-gray-600" />
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-gray-600" />
Other Supporting Documents
</h3>
<Badge variant="secondary" className="text-xs">
{proposalData.otherDocuments.length} file(s)
</Badge>
</div>
<div className="space-y-2">
<div className="space-y-2 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
{proposalData.otherDocuments.map((doc, index) => (
<div
key={index}
className="border rounded-lg p-3 bg-gray-50 flex items-center justify-between"
className="border rounded-lg p-2 lg:p-3 bg-gray-50 flex items-center justify-between"
>
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-gray-600" />
<p className="text-sm font-medium text-gray-900">{doc.name}</p>
<div className="flex items-center gap-2 lg:gap-3">
<FileText className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600" />
<p className="text-xs lg:text-sm font-medium text-gray-900">{doc.name}</p>
</div>
{doc.id && (
<div className="flex items-center gap-1">
@ -488,44 +492,45 @@ export function InitiatorProposalApprovalModal({
)}
{/* Dealer Comments */}
<div className="space-y-3">
<div className="space-y-2 lg:space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-blue-600" />
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-blue-600" />
Dealer Comments
</h3>
</div>
<div className="border rounded-lg p-4 bg-gray-50">
<p className="text-sm text-gray-700 whitespace-pre-wrap">
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
<p className="text-xs text-gray-700 whitespace-pre-wrap">
{proposalData?.dealerComments || 'No comments provided'}
</p>
</div>
</div>
{/* Decision Section */}
<div className="space-y-3 border-t pt-4">
<h3 className="font-semibold text-lg">Your Decision & Comments</h3>
<div className="space-y-2 lg:space-y-2 border-t pt-3 lg:pt-3 lg:col-span-2">
<h3 className="font-semibold text-sm lg:text-base">Your Decision & Comments</h3>
<Textarea
placeholder="Provide your evaluation comments, approval conditions, or rejection reasons..."
value={comments}
onChange={(e) => setComments(e.target.value)}
className="min-h-[120px]"
className="min-h-[80px] lg:min-h-[90px] text-xs lg:text-sm"
/>
<p className="text-xs text-gray-500">{comments.length} characters</p>
</div>
{/* Warning for missing comments */}
{!comments.trim() && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 flex items-start gap-2">
<XCircle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-800">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 flex items-start gap-2 lg:col-span-2">
<XCircle className="w-3.5 h-3.5 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800">
Please provide comments before making a decision. Comments are required and will be visible to all participants.
</p>
</div>
)}
</div>
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end px-6 pb-6 pt-4 flex-shrink-0 border-t bg-gray-50">
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end px-6 pb-6 pt-3 lg:pt-4 flex-shrink-0 border-t bg-gray-50">
<Button
variant="outline"
onClick={handleClose}

View File

@ -85,6 +85,10 @@ export function useModalManager(
// API Call: Submit approval
await approveLevel(requestIdentifier, levelId, description || '');
// Small delay to ensure backend has fully processed the approval and updated the status
// This is especially important for additional approvers where the workflow moves to the next step
await new Promise(resolve => setTimeout(resolve, 500));
// Refresh: Update UI with new approval status
await refreshDetails();

View File

@ -1,13 +1,16 @@
import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { LogIn } from 'lucide-react';
import { LogIn, Shield } from 'lucide-react';
import { ReLogo } from '@/assets';
import { initiateTanflowLogin } from '@/services/tanflowAuth';
export function Auth() {
const { login, isLoading, error } = useAuth();
const [tanflowLoading, setTanflowLoading] = useState(false);
const handleSSOLogin = async () => {
const handleOKTALogin = async () => {
// Clear any existing session data
localStorage.clear();
sessionStorage.clear();
@ -16,7 +19,7 @@ export function Auth() {
await login();
} catch (loginError) {
console.error('========================================');
console.error('LOGIN ERROR');
console.error('OKTA LOGIN ERROR');
console.error('Error details:', loginError);
console.error('Error message:', (loginError as Error)?.message);
console.error('Error stack:', (loginError as Error)?.stack);
@ -24,8 +27,24 @@ export function Auth() {
}
};
const handleTanflowLogin = () => {
// Clear any existing session data
localStorage.clear();
sessionStorage.clear();
setTanflowLoading(true);
try {
initiateTanflowLogin();
} catch (loginError) {
console.error('========================================');
console.error('TANFLOW LOGIN ERROR');
console.error('Error details:', loginError);
setTanflowLoading(false);
}
};
if (error) {
console.error('Auth0 Error in Auth Component:', {
console.error('Auth Error in Auth Component:', {
message: error.message,
error: error
});
@ -53,30 +72,62 @@ export function Auth() {
</div>
)}
<Button
onClick={handleSSOLogin}
disabled={isLoading}
className="w-full h-12 text-base font-semibold bg-re-red hover:bg-re-red/90 text-white"
size="lg"
>
{isLoading ? (
<>
<div
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
/>
Logging in...
</>
) : (
<>
<LogIn className="mr-2 h-5 w-5" />
SSO Login
</>
)}
</Button>
<div className="space-y-3">
<Button
onClick={handleOKTALogin}
disabled={isLoading || tanflowLoading}
className="w-full h-12 text-base font-semibold bg-re-red hover:bg-re-red/90 text-white"
size="lg"
>
{isLoading ? (
<>
<div
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
/>
Logging in...
</>
) : (
<>
<LogIn className="mr-2 h-5 w-5" />
SSO with OKTA
</>
)}
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-300"></span>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-500">Or</span>
</div>
</div>
<Button
onClick={handleTanflowLogin}
disabled={isLoading || tanflowLoading}
className="w-full h-12 text-base font-semibold bg-indigo-600 hover:bg-indigo-700 text-white"
size="lg"
>
{tanflowLoading ? (
<>
<div
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
/>
Redirecting...
</>
) : (
<>
<Shield className="mr-2 h-5 w-5" />
SSO with Tanflow
</>
)}
</Button>
</div>
<div className="text-center text-sm text-gray-500 mt-4">
<p>Secure Single Sign-On</p>
<p className="text-xs mt-1">Powered by Auth0</p>
<p className="text-xs mt-1">Choose your authentication provider</p>
</div>
</CardContent>
</Card>

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { Auth } from './Auth';
import { AuthCallback } from './AuthCallback';
import { TanflowCallback } from './TanflowCallback';
import { AuthDebugInfo } from '@/components/Auth/AuthDebugInfo';
import App from '../../App';
@ -10,7 +11,8 @@ export function AuthenticatedApp() {
const [showDebugInfo, setShowDebugInfo] = useState(false);
// Check if we're on callback route (after all hooks are called)
const isCallbackRoute = typeof window !== 'undefined' && window.location.pathname === '/login/callback';
const isCallbackRoute = typeof window !== 'undefined' &&
window.location.pathname === '/login/callback';
const handleLogout = async () => {
try {
@ -39,7 +41,35 @@ export function AuthenticatedApp() {
}, [isAuthenticated, isLoading, error, user]);
// Always show callback loader when on callback route (after all hooks)
// Detect provider from sessionStorage to show appropriate callback component
if (isCallbackRoute) {
// Check if this is a logout redirect (no code, no error)
const urlParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
const hasCode = urlParams?.get('code');
const hasError = urlParams?.get('error');
// If no code and no error, it's a logout redirect - redirect immediately
if (!hasCode && !hasError) {
console.log('🚪 AuthenticatedApp: Logout redirect detected, redirecting to home');
const logoutParams = new URLSearchParams();
logoutParams.set('tanflow_logged_out', 'true');
logoutParams.set('logout', Date.now().toString());
window.location.replace(`/?${logoutParams.toString()}`);
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-slate-900 border-t-transparent mx-auto mb-4"></div>
<p className="text-gray-600">Redirecting...</p>
</div>
</div>
);
}
const authProvider = typeof window !== 'undefined' ? sessionStorage.getItem('auth_provider') : null;
if (authProvider === 'tanflow') {
return <TanflowCallback />;
}
// Default to OKTA callback (or if provider not set yet)
return <AuthCallback />;
}

View File

@ -0,0 +1,301 @@
/**
* Tanflow OAuth Callback Handler
* Handles the redirect from Tanflow SSO after authentication
*/
import { useEffect, useState, useRef } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { exchangeTanflowCodeForTokens } from '@/services/tanflowAuth';
import { getCurrentUser } from '@/services/authApi';
import { TokenManager } from '@/utils/tokenManager';
import { CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
import { ReLogo } from '@/assets';
export function TanflowCallback() {
const { isAuthenticated, isLoading, error, user } = useAuth();
const [authStep, setAuthStep] = useState<'exchanging' | 'fetching' | 'complete' | 'error'>('exchanging');
const [errorMessage, setErrorMessage] = useState<string>('');
const callbackProcessedRef = useRef(false);
useEffect(() => {
// Determine current authentication step based on state
if (error) {
setAuthStep('error');
return;
}
if (isLoading) {
const urlParams = new URLSearchParams(window.location.search);
const hasCode = urlParams.get('code');
if (hasCode && !user) {
setAuthStep('exchanging');
} else if (user && !isAuthenticated) {
setAuthStep('fetching');
} else {
setAuthStep('exchanging');
}
} else if (user && isAuthenticated) {
setAuthStep('complete');
// If already authenticated, redirect immediately
// This handles the case where auth state was set before this component rendered
setTimeout(() => {
window.location.href = '/';
}, 1000);
}
}, [isAuthenticated, isLoading, error, user]);
// Handle Tanflow callback
useEffect(() => {
// Only process if we're on the callback route
if (callbackProcessedRef.current || window.location.pathname !== '/login/callback') {
return;
}
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const errorParam = urlParams.get('error');
// SIMPLIFIED: If no code and no error, it's a logout redirect - redirect immediately
// Tanflow logout redirects back to /login/callback without any parameters
if (!code && !errorParam) {
console.log('🚪 Logout redirect detected: no code, no error - redirecting to home immediately');
callbackProcessedRef.current = true;
// Redirect to home with logout flags
const logoutParams = new URLSearchParams();
logoutParams.set('tanflow_logged_out', 'true');
logoutParams.set('logout', Date.now().toString());
const redirectUrl = `/?${logoutParams.toString()}`;
console.log('🚪 Redirecting to:', redirectUrl);
window.location.replace(redirectUrl);
return;
}
// Check if this is a Tanflow callback
const authProvider = sessionStorage.getItem('auth_provider');
if (authProvider !== 'tanflow') {
// Not a Tanflow callback, let AuthContext handle it
return;
}
const handleCallback = async () => {
callbackProcessedRef.current = true;
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const errorParam = urlParams.get('error');
// Clean URL immediately
window.history.replaceState({}, document.title, '/login/callback');
// Check for errors from Tanflow
if (errorParam) {
setAuthStep('error');
sessionStorage.removeItem('auth_provider');
sessionStorage.removeItem('tanflow_auth_state');
return;
}
// Validate state
const storedState = sessionStorage.getItem('tanflow_auth_state');
if (state && state !== storedState) {
setAuthStep('error');
sessionStorage.removeItem('auth_provider');
sessionStorage.removeItem('tanflow_auth_state');
return;
}
if (!code) {
setAuthStep('error');
sessionStorage.removeItem('auth_provider');
sessionStorage.removeItem('tanflow_auth_state');
return;
}
try {
setAuthStep('exchanging');
// Exchange code for tokens (this stores tokens in TokenManager)
const tokenData = await exchangeTanflowCodeForTokens(code, state || '');
// Clear state but keep provider flag for logout detection
sessionStorage.removeItem('tanflow_auth_state');
// Keep auth_provider in sessionStorage so logout can detect which provider to use
// This will be cleared during logout
setAuthStep('fetching');
// Fetch user profile (tokenData already has user, but fetch to ensure it's current)
const userData = tokenData.user || await getCurrentUser();
if (userData) {
// Store user data in TokenManager (already stored by exchangeTanflowCodeForTokens, but ensure it's set)
TokenManager.setUserData(userData);
// Show success message briefly
setAuthStep('complete');
// Clean URL and do full page reload to ensure AuthContext checks auth status
// This is necessary because AuthContext skips auth check on /login/callback route
// After reload, AuthContext will check tokens and set isAuthenticated/user properly
setTimeout(() => {
window.history.replaceState({}, document.title, '/');
// Use window.location.href for full page reload to trigger AuthContext initialization
window.location.href = '/';
}, 1000);
} else {
throw new Error('User data not received');
}
} catch (err: any) {
console.error('Tanflow callback error:', err);
setAuthStep('error');
setErrorMessage(err.message || 'Authentication failed');
sessionStorage.removeItem('auth_provider');
sessionStorage.removeItem('tanflow_auth_state');
}
};
handleCallback();
}, []);
const getLoadingMessage = () => {
switch (authStep) {
case 'exchanging':
return 'Exchanging authorization code...';
case 'fetching':
return 'Fetching your profile...';
case 'complete':
return 'Authentication successful!';
case 'error':
return 'Authentication failed';
default:
return 'Completing authentication...';
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiMxZTIxMmQiIGZpbGwtb3BhY2l0eT0iMC4wNSI+PGNpcmNsZSBjeD0iMzAiIGN5PSIzMCIgcj0iMzAiLz48L2c+PC9nPjwvc3ZnPg==')] opacity-20"></div>
<div className="relative z-10 text-center px-4 max-w-md w-full">
{/* Logo/Brand Section */}
<div className="mb-8">
<div className="flex flex-col items-center justify-center">
<img
src={ReLogo}
alt="Royal Enfield Logo"
className="h-10 w-auto max-w-[168px] object-contain mb-2"
/>
<p className="text-xs text-gray-400 text-center truncate">Approval Portal</p>
</div>
</div>
{/* Main Loader Card */}
<div className="bg-white/10 backdrop-blur-xl rounded-2xl p-8 shadow-2xl border border-white/20">
{/* Status Icon */}
<div className="mb-6 flex justify-center">
{authStep === 'error' ? (
<div className="relative">
<div className="absolute inset-0 animate-ping opacity-75">
<AlertCircle className="w-16 h-16 text-red-500" />
</div>
<AlertCircle className="w-16 h-16 text-red-500 relative" />
</div>
) : authStep === 'complete' ? (
<div className="relative">
<div className="absolute inset-0 animate-ping opacity-75">
<CheckCircle2 className="w-16 h-16 text-green-500" />
</div>
<CheckCircle2 className="w-16 h-16 text-green-500 relative" />
</div>
) : (
<div className="relative">
<Loader2 className="w-16 h-16 animate-spin text-re-red" />
<div className="absolute inset-0 border-4 rounded-full border-re-red/20"></div>
<div className="absolute inset-0 border-4 border-transparent border-t-re-red rounded-full animate-spin"></div>
</div>
)}
</div>
{/* Loading Message */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-white mb-2">
{authStep === 'complete' ? 'Welcome Back!' : authStep === 'error' ? 'Authentication Error' : 'Authenticating'}
</h2>
<p className="text-slate-300 text-sm">{getLoadingMessage()}</p>
</div>
{/* Progress Steps */}
{authStep !== 'error' && (
<div className="space-y-3 mb-6">
<div className={`flex items-center gap-3 text-sm transition-all duration-500 ${authStep === 'exchanging' ? 'text-white' : 'text-slate-400'}`}>
<div className={`w-2 h-2 rounded-full transition-all duration-500 ${authStep === 'exchanging' ? 'bg-re-red animate-pulse' : 'bg-slate-600'}`}></div>
<span>Validating credentials</span>
</div>
<div className={`flex items-center gap-3 text-sm transition-all duration-500 ${authStep === 'fetching' ? 'text-white' : 'text-slate-400'}`}>
<div className={`w-2 h-2 rounded-full transition-all duration-500 ${authStep === 'fetching' ? 'bg-re-red animate-pulse' : 'bg-slate-600'}`}></div>
<span>Loading your profile</span>
</div>
{authStep === 'complete' && (
<div className="flex items-center gap-3 text-sm transition-all duration-500 text-white">
<div className="w-2 h-2 rounded-full transition-all duration-500 bg-green-500"></div>
<span>Setting up your session</span>
</div>
)}
</div>
)}
{/* Error Message */}
{authStep === 'error' && errorMessage && (
<div className="mt-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
<p className="text-red-400 text-sm">{errorMessage}</p>
<button
onClick={() => {
window.location.href = '/';
}}
className="mt-4 text-sm text-red-400 hover:text-red-300 underline"
>
Return to login
</button>
</div>
)}
{/* Animated Progress Bar */}
{authStep !== 'error' && authStep !== 'complete' && (
<div className="mt-6">
<div className="h-1.5 bg-slate-700/50 rounded-full overflow-hidden">
<div
className="h-full bg-re-red rounded-full animate-pulse"
style={{
animation: 'progress 2s ease-in-out infinite',
}}
></div>
</div>
<style>{`
@keyframes progress {
0%, 100% { width: 20%; }
50% { width: 80%; }
}
`}</style>
</div>
)}
</div>
{/* Footer Text */}
<p className="mt-6 text-slate-500 text-xs">
{authStep === 'complete' ? 'Loading dashboard...' : 'Please wait while we secure your session'}
</p>
</div>
{/* Animated Background Elements */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-re-red/5 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-re-red/5 rounded-full blur-3xl animate-pulse delay-1000"></div>
</div>
</div>
);
}

201
src/services/tanflowAuth.ts Normal file
View File

@ -0,0 +1,201 @@
/**
* Tanflow Authentication Service
* Handles OAuth flow with Tanflow IAM Suite
*/
import { TokenManager } from '../utils/tokenManager';
import axios from 'axios';
const TANFLOW_BASE_URL = import.meta.env.VITE_TANFLOW_BASE_URL || 'https://ssodev.rebridge.co.in/realms/RE';
const TANFLOW_CLIENT_ID = import.meta.env.VITE_TANFLOW_CLIENT_ID || 'REFLOW';
const TANFLOW_REDIRECT_URI = `${window.location.origin}/login/callback`;
/**
* Initiate Tanflow SSO login
* Redirects user to Tanflow authorization endpoint
*/
export function initiateTanflowLogin(): void {
// 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('tanflow_logged_out');
// Clear any previous logout flags before starting new login
if (isAfterLogout) {
sessionStorage.removeItem('tanflow_logged_out');
sessionStorage.removeItem('__logout_in_progress__');
sessionStorage.removeItem('__force_logout__');
console.log('🚪 Cleared logout flags before initiating Tanflow login');
}
const state = Math.random().toString(36).substring(7);
// Store provider type and state to identify Tanflow callback
sessionStorage.setItem('auth_provider', 'tanflow');
sessionStorage.setItem('tanflow_auth_state', state);
let authUrl = `${TANFLOW_BASE_URL}/protocol/openid-connect/auth?` +
`client_id=${TANFLOW_CLIENT_ID}&` +
`response_type=code&` +
`scope=openid&` +
`redirect_uri=${encodeURIComponent(TANFLOW_REDIRECT_URI)}&` +
`state=${state}`;
// Add prompt=login if coming from logout to force re-authentication
// This ensures Tanflow requires login even if a session still exists
if (isAfterLogout) {
authUrl += `&prompt=login`;
console.log('🚪 Adding prompt=login to force re-authentication after logout');
}
console.log('🚪 Initiating Tanflow login', { isAfterLogout, hasPrompt: isAfterLogout });
window.location.href = authUrl;
}
/**
* Exchange authorization code for tokens via backend
* Backend handles the token exchange securely with client secret
*/
export async function exchangeTanflowCodeForTokens(
code: string,
state: string
): Promise<{
accessToken: string;
refreshToken: string;
idToken: string;
user: any;
}> {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
try {
const response = await axios.post(
`${API_BASE_URL}/auth/tanflow/token-exchange`,
{
code,
redirectUri: TANFLOW_REDIRECT_URI,
state,
},
{
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
}
);
const data = response.data?.data || response.data;
// Store tokens
if (data.accessToken) {
TokenManager.setAccessToken(data.accessToken);
}
if (data.refreshToken) {
TokenManager.setRefreshToken(data.refreshToken);
}
if (data.idToken) {
TokenManager.setIdToken(data.idToken);
}
if (data.user) {
TokenManager.setUserData(data.user);
}
return data;
} catch (error: any) {
console.error('❌ Tanflow token exchange failed:', {
message: error.message,
response: error.response?.data,
status: error.response?.status,
});
throw error;
}
}
/**
* Refresh access token using refresh token
*/
export async function refreshTanflowToken(): Promise<string> {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
const refreshToken = TokenManager.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
try {
const response = await axios.post(
`${API_BASE_URL}/auth/tanflow/refresh`,
{ refreshToken },
{
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
}
);
const data = response.data?.data || response.data;
const accessToken = data.accessToken;
if (accessToken) {
TokenManager.setAccessToken(accessToken);
return accessToken;
}
throw new Error('Failed to refresh token');
} catch (error: any) {
console.error('❌ Tanflow token refresh failed:', error);
throw error;
}
}
/**
* Logout from Tanflow
* Uses id_token for logout and redirects back to app
* Note: This should be called AFTER backend logout API is called and tokens are cleared
* DO NOT clear tokens here - they should already be cleared by AuthContext
*/
export function tanflowLogout(idToken: string): void {
if (!idToken) {
console.warn('🚪 No id_token available for Tanflow logout, redirecting to home');
// Fallback: redirect to home with logout flags (similar to OKTA approach)
const homeUrl = `${window.location.origin}/?tanflow_logged_out=true&logout=${Date.now()}`;
window.location.replace(homeUrl);
return;
}
// Build Tanflow logout URL with redirect back to login callback
// IMPORTANT: Use the base redirect URI (without query params) to match registered URIs
// Tanflow requires exact match with registered "Valid Post Logout Redirect URIs"
// The same URI used for login should be registered for logout
// Using the base URI ensures it matches what's registered in Tanflow client config
const postLogoutRedirectUri = TANFLOW_REDIRECT_URI; // Use base URI without query params
// Construct logout URL - ensure all parameters are properly encoded
// Keycloak/Tanflow expects: client_id, id_token_hint, post_logout_redirect_uri
const logoutUrl = new URL(`${TANFLOW_BASE_URL}/protocol/openid-connect/logout`);
logoutUrl.searchParams.set('client_id', TANFLOW_CLIENT_ID);
logoutUrl.searchParams.set('id_token_hint', idToken);
logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
const finalLogoutUrl = logoutUrl.toString();
console.log('🚪 Tanflow logout initiated', {
hasIdToken: !!idToken,
idTokenPrefix: idToken ? idToken.substring(0, 20) + '...' : 'none',
postLogoutRedirectUri,
logoutUrlBase: `${TANFLOW_BASE_URL}/protocol/openid-connect/logout`,
finalLogoutUrl: finalLogoutUrl.replace(idToken.substring(0, 20), '***'),
});
// DO NOT clear auth_provider here - we need it to detect Tanflow callback
// The logout flags should already be set by AuthContext
// Just ensure they're there
sessionStorage.setItem('__logout_in_progress__', 'true');
sessionStorage.setItem('__force_logout__', 'true');
// Don't set tanflow_logged_out here - it will be set when Tanflow redirects back
// Redirect to Tanflow logout endpoint
// Tanflow will clear the session and redirect back to post_logout_redirect_uri
// The redirect will include tanflow_logged_out=true in the query params
console.log('🚪 Redirecting to Tanflow logout endpoint...');
window.location.href = finalLogoutUrl;
}