dealer claim steps reduced and the modal popup layout changed and tanflow login added
This commit is contained in:
parent
01d69bb1eb
commit
ce90fcf9ef
@ -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 />}
|
||||
|
||||
@ -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()}`;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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">
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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 && (() => {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
301
src/pages/Auth/TanflowCallback.tsx
Normal file
301
src/pages/Auth/TanflowCallback.tsx
Normal 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
201
src/services/tanflowAuth.ts
Normal 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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user