-
-
{formatCurrency(creditNoteAmount)}
+ {/* Financial Summary */}
+
+
+
+
{formatCurrency(creditNoteAmount)}
+
+
+
+
{formatCurrency(tdsAmount)}
+
+
+
+
{formatCurrency(creditAmount)}
+
+
+ {/* Line Items Section */}
+ {items && items.length > 0 && (
+
+
+
+
+ Line Item Breakdown
+
+
+ {items.length} {items.length === 1 ? 'Item' : 'Items'}
+
+
+
+
+
+
+ | Sl No |
+ Transaction Code |
+ HSN/SAC |
+ Claim Amount |
+ TDS |
+ Net Credit |
+
+
+
+ {items.map((item, idx) => (
+
+ | {item.slNo} |
+ {item.transactionNo || 'N/A'} |
+ {item.hsnCd || 'N/A'} |
+ {formatCurrency(item.claimAmount || 0)} |
+ {formatCurrency(item.tdsAmount || 0)} |
+ {formatCurrency(item.creditAmount || 0)} |
+
+ ))}
+
+
+
+
+ )}
>
) : (
/* No Credit Note Available */
diff --git a/src/dealer-claim/components/request-detail/modals/DealerCompletionDocumentsModal.tsx b/src/dealer-claim/components/request-detail/modals/DealerCompletionDocumentsModal.tsx
index 7179701..5e18123 100644
--- a/src/dealer-claim/components/request-detail/modals/DealerCompletionDocumentsModal.tsx
+++ b/src/dealer-claim/components/request-detail/modals/DealerCompletionDocumentsModal.tsx
@@ -13,6 +13,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
+import { HsnSacSelector } from "@/components/common/HsnSacSelector";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -344,6 +345,10 @@ export function DealerCompletionDocumentsModal({
const calculation = calculateGST(amount, { cgstRate, sgstRate, igstRate, utgstRate }, quantity);
+ if (field === 'isService') {
+ updatedItem.hsnCode = '';
+ }
+
return {
...updatedItem,
amount,
@@ -351,6 +356,10 @@ export function DealerCompletionDocumentsModal({
...calculation
};
}
+
+ if (field === 'isService') {
+ updatedItem.hsnCode = '';
+ }
return updatedItem;
}
@@ -757,18 +766,17 @@ export function DealerCompletionDocumentsModal({
)}
{!isNonGst && (
<>
-
+
-
- handleExpenseChange(item.id, 'hsnCode', e.target.value)
- }
- className={`w-full bg-white text-sm ${!validateHSNSAC(item.hsnCode, item.isService).isValid ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
+ onChange={(value) => handleExpenseChange(item.id, 'hsnCode', value)}
+ type={item.isService ? 'SAC' : 'HSN'}
+ className={!validateHSNSAC(item.hsnCode, item.isService).isValid ? 'border-red-500' : ''}
+ placeholder={item.isService ? 'SAC' : 'HSN'}
/>
{!validateHSNSAC(item.hsnCode, item.isService).isValid && (
-
+
{validateHSNSAC(item.hsnCode, item.isService).message}
)}
diff --git a/src/dealer-claim/components/request-detail/modals/DealerProposalSubmissionModal.tsx b/src/dealer-claim/components/request-detail/modals/DealerProposalSubmissionModal.tsx
index 78501a1..d737824 100644
--- a/src/dealer-claim/components/request-detail/modals/DealerProposalSubmissionModal.tsx
+++ b/src/dealer-claim/components/request-detail/modals/DealerProposalSubmissionModal.tsx
@@ -15,6 +15,7 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
+import { HsnSacSelector } from "@/components/common/HsnSacSelector";
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
@@ -1008,14 +1009,21 @@ export function DealerProposalSubmissionModal({
/>
-
+
- handleCostItemChange(item.id, 'hsnCode', e.target.value)}
- className={`bg-white shadow-sm ${!validateHSNSAC(item.hsnCode, item.isService).isValid ? 'border-red-500' : ''} ${item.isOriginal ? 'bg-gray-100 cursor-not-allowed' : ''}`}
+ onChange={(value) => handleCostItemChange(item.id, 'hsnCode', value)}
+ type={item.isService ? 'SAC' : 'HSN'}
disabled={item.isOriginal}
+ className={!validateHSNSAC(item.hsnCode, item.isService).isValid ? 'border-red-500' : ''}
+ placeholder={item.isService ? 'Select SAC' : 'Select HSN'}
/>
+ {!validateHSNSAC(item.hsnCode, item.isService).isValid && (
+
+ {validateHSNSAC(item.hsnCode, item.isService).message}
+
+ )}
diff --git a/src/main.tsx b/src/main.tsx
index 5993aca..bfe941a 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,8 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
-import { AuthProvider } from './contexts/AuthContext';
-import { AuthenticatedApp } from './pages/Auth';
+import { AuthProvider } from '@/contexts/AuthContext';
+import { AuthenticatedApp } from '@/pages/Auth';
import { store } from './redux/store';
import './styles/globals.css';
import './styles/base-layout.css';
diff --git a/src/pages/Auth/AuthenticatedApp.tsx b/src/pages/Auth/AuthenticatedApp.tsx
index 4c10584..b759264 100644
--- a/src/pages/Auth/AuthenticatedApp.tsx
+++ b/src/pages/Auth/AuthenticatedApp.tsx
@@ -4,6 +4,7 @@ import { Auth } from './Auth';
import { AuthCallback } from './AuthCallback';
import { TanflowCallback } from './TanflowCallback';
import { AuthDebugInfo } from '@/components/Auth/AuthDebugInfo';
+import { TokenManager } from '@/utils/tokenManager';
import App from '../../App';
export function AuthenticatedApp() {
@@ -40,31 +41,14 @@ export function AuthenticatedApp() {
// Auth state changed
}, [isAuthenticated, isLoading, error, user]);
+ // Check for callback parameters
+ const urlParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
+ const hasCode = urlParams?.get('code');
+ const hasError = urlParams?.get('error');
+
// 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 (
-
- );
- }
-
+ // ONLY if it's a real login callback (has code/error) OR if we are still authenticated
+ if (isCallbackRoute && (hasCode || hasError || isAuthenticated)) {
const authProvider = typeof window !== 'undefined' ? sessionStorage.getItem('auth_provider') : null;
if (authProvider === 'tanflow') {
return
;
@@ -74,7 +58,8 @@ export function AuthenticatedApp() {
}
// Show loading state while checking authentication
- if (isLoading) {
+ // UNLESS a session supersession is in progress (in which case we want the app UI to stay visible while toast is shown)
+ if (isLoading && TokenManager.getAuthError() !== 'SESSION_SUPERSEDED') {
return (
diff --git a/src/pages/Settings/Settings.tsx b/src/pages/Settings/Settings.tsx
index dec5c66..c4b0447 100644
--- a/src/pages/Settings/Settings.tsx
+++ b/src/pages/Settings/Settings.tsx
@@ -18,6 +18,7 @@ import { HolidayManager } from '@/components/admin/HolidayManager';
import { UserRoleManager } from '@/components/admin/UserRoleManager';
import { ActivityTypeManager } from '@/components/admin/ActivityTypeManager';
import { Form16AdminConfig } from '@/components/admin/Form16AdminConfig/Form16AdminConfig';
+import { HsnSacCodeManager } from '@/components/admin/HsnSacCodeManager';
import { NotificationStatusModal } from '@/components/settings/NotificationStatusModal';
import { NotificationPreferencesModal } from '@/components/settings/NotificationPreferencesModal';
// import { ApiTokenManager } from '@/components/settings/ApiTokenManager'; // Removed: Moved to dedicated page
@@ -38,6 +39,7 @@ export function Settings() {
const [checkingSubscription, setCheckingSubscription] = useState(true);
const [showActivityTypeManager, setShowActivityTypeManager] = useState(false);
const [showForm16AdminConfig, setShowForm16AdminConfig] = useState(false);
+ const [showHsnSacCodeManager, setShowHsnSacCodeManager] = useState(false);
useEffect(() => {
checkSubscriptionStatus();
@@ -425,6 +427,38 @@ export function Settings() {
+ ) : showHsnSacCodeManager ? (
+
+
+
+
+
+
+
+
+
+
+ HSN/SAC Code Configuration
+
+ Manage HSN/SAC codes and associated GST rates
+
+
+
+
+
+
+
+
) : (
@@ -498,6 +532,34 @@ export function Settings() {
+ {/* HSN/SAC Code Configuration Card */}
+
setShowHsnSacCodeManager(true)}
+ >
+
+
+
+
+
+
+
+
+ HSN/SAC Code Configuration
+
+
+ Manage HSN/SAC codes for recoveries and claims
+
+
+
+
+
+
+
diff --git a/src/services/authApi.ts b/src/services/authApi.ts
index e8669ec..03b6739 100644
--- a/src/services/authApi.ts
+++ b/src/services/authApi.ts
@@ -5,6 +5,34 @@
import axios, { AxiosInstance } from 'axios';
import { TokenManager } from '../utils/tokenManager';
+import { toast } from 'sonner';
+import { tanflowLogout } from './tanflowAuth';
+
+/**
+ * Perform a formal logout from Okta by redirecting the browser
+ * This clears the SSO session on the Okta side
+ */
+export function oktaLogout(idToken?: string): void {
+ const oktaDomain = import.meta.env.VITE_OKTA_DOMAIN || '{{IDP_DOMAIN}}';
+ // Use the exact whitelisted login callback URI without query params to avoid mismatch errors
+ const redirectUri = `${window.location.origin}/login/callback`;
+
+ if (idToken) {
+ // Persist logout flag in sessionStorage before redirecting
+ // This allows AuthContext to detect the return from logout without query params
+ sessionStorage.setItem('__logout_type__', 'okta');
+
+ // Standard OIDC logout redirect
+ const logoutUrl = `${oktaDomain}/oauth2/default/v1/logout?id_token_hint=${idToken}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`;
+ console.log('🚪 Initiating Okta logout redirect to callback (clean URI)');
+ window.location.href = logoutUrl;
+ } else {
+ // Fallback: redirect to callback with logout flag in sessionStorage
+ sessionStorage.setItem('__logout_type__', 'okta');
+ console.log('🚪 No id_token for Okta logout, redirecting to callback');
+ window.location.href = redirectUri;
+ }
+}
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
@@ -70,48 +98,74 @@ apiClient.interceptors.response.use(
}
}
- // If error is 401 and we haven't retried yet
- if (error.response?.status === 401 && !originalRequest._retry) {
- originalRequest._retry = true;
+ // If error is 401
+ if (error.response?.status === 401) {
+ // Check for concurrent session logout specifically
+ if (error.response?.data?.errorCode === 'SESSION_SUPERSEDED') {
+ const idToken = TokenManager.getIdToken();
+ const authProvider = sessionStorage.getItem('auth_provider') || (idToken?.includes('tanflow') ? 'tanflow' : 'okta');
- const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
+ // Set error state in TokenManager to stop background refreshes
+ TokenManager.setAuthError('SESSION_SUPERSEDED');
+
+ // Show the toast immediately
+ toast.error("You have been logged out because an active session was detected from another device.", {
+ duration: 2000,
+ id: 'session-superseded-toast'
+ });
- try {
- // Attempt to refresh token
- // In production: Cookie is sent automatically via withCredentials
- // In development: Send refresh token from localStorage
- const refreshToken = TokenManager.getRefreshToken();
+ // Delay sets flags and redirect so user can read the toast before UI state clears
+ setTimeout(async () => {
+ // Set flags JUST BEFORE redirect to ensure AuthContext only picks them up on return/reload
+ sessionStorage.setItem('__logout_in_progress__', 'true');
+ sessionStorage.setItem('__force_logout__', 'true');
+ // IdP Logout FIRST as requested (clears SSO session)
+ // Note: Backend logout will be handled by AuthContext on return redirect
+ if (authProvider === 'tanflow' && idToken) {
+ tanflowLogout(idToken);
+ } else {
+ oktaLogout(idToken || undefined);
+ }
+ }, 1000);
- // In production, refreshToken will be null but cookie will be sent
- // In development, we need the token in body
- if (!isProduction && !refreshToken) {
- throw new Error('No refresh token available');
+ return Promise.reject(error);
+ }
+
+ // Handle token refresh if not already retrying
+ if (!originalRequest._retry) {
+ originalRequest._retry = true;
+ const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
+
+ try {
+ // Attempt to refresh token
+ const refreshToken = TokenManager.getRefreshToken();
+
+ if (!isProduction && !refreshToken) {
+ throw new Error('No refresh token available');
+ }
+
+ const response = await axios.post(
+ `${API_BASE_URL}/auth/refresh`,
+ isProduction ? {} : { refreshToken },
+ { withCredentials: true }
+ );
+
+ const responseData = response.data.data || response.data;
+ const accessToken = responseData.accessToken;
+
+ if (!isProduction && accessToken) {
+ TokenManager.setAccessToken(accessToken);
+ originalRequest.headers.Authorization = `Bearer ${accessToken}`;
+ }
+
+ // Retry the original request
+ return apiClient(originalRequest);
+ } catch (refreshError) {
+ // Refresh failed, clear tokens and redirect to login
+ TokenManager.clearAll();
+ window.location.href = '/';
+ return Promise.reject(refreshError);
}
-
- const response = await axios.post(
- `${API_BASE_URL}/auth/refresh`,
- isProduction ? {} : { refreshToken }, // Empty body in production, cookie is used
- { withCredentials: true }
- );
-
- const responseData = response.data.data || response.data;
- const accessToken = responseData.accessToken;
-
- // In production: Backend sets new httpOnly cookie, no token in response
- // In development: Token is in response, store it and add to header
- if (!isProduction && accessToken) {
- TokenManager.setAccessToken(accessToken);
- originalRequest.headers.Authorization = `Bearer ${accessToken}`;
- }
-
- // Retry the original request
- // In production: Cookie will be sent automatically
- return apiClient(originalRequest);
- } catch (refreshError) {
- // Refresh failed, clear tokens and redirect to login
- TokenManager.clearAll();
- window.location.href = '/';
- return Promise.reject(refreshError);
}
}
@@ -287,8 +341,8 @@ export async function getCurrentUser() {
*/
export async function logout(): Promise
{
try {
- // Use withCredentials to ensure cookies are sent
- await apiClient.post('/auth/logout', {}, {
+ // Use axios directly to avoid interceptor recursion
+ await axios.post(`${API_BASE_URL}/auth/logout`, {}, {
withCredentials: true, // Ensure cookies are sent with request
});
} catch (error: any) {
diff --git a/src/services/dashboard.service.ts b/src/services/dashboard.service.ts
index 62e2d05..0d0bc86 100644
--- a/src/services/dashboard.service.ts
+++ b/src/services/dashboard.service.ts
@@ -487,7 +487,6 @@ class DashboardService {
params.slaCompliance = slaCompliance;
}
- console.log('[Dashboard Service] Fetching approver performance with params:', params);
const response = await apiClient.get('/dashboard/stats/approver-performance', { params });
return {
diff --git a/src/services/hsnSacCodeApi.ts b/src/services/hsnSacCodeApi.ts
new file mode 100644
index 0000000..544d69d
--- /dev/null
+++ b/src/services/hsnSacCodeApi.ts
@@ -0,0 +1,103 @@
+import apiClient from './authApi';
+
+export type CodeType = 'HSN' | 'SAC';
+
+export interface HsnSacCode {
+ id: string;
+ code: string;
+ type: CodeType;
+ gstRate?: number;
+ description?: string;
+ isActive: boolean;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CreateHsnSacCodeDTO {
+ code: string;
+ type: CodeType;
+ gstRate?: number;
+ description?: string;
+ isActive?: boolean;
+}
+
+export interface HsnSacCodeResponse {
+ codes: HsnSacCode[];
+ pagination: {
+ totalRecords: number;
+ totalPages: number;
+ currentPage: number;
+ limit: number;
+ };
+}
+
+/**
+ * Get all HSN/SAC codes with pagination and search
+ */
+export async function getAllHsnSacCodes(
+ onlyActive: boolean = false,
+ page: number = 1,
+ limit: number = 10,
+ search?: string
+): Promise {
+ const params: any = { active: onlyActive, page, limit };
+ if (search) params.search = search;
+
+ const response = await apiClient.get('/hsn-sac', { params });
+
+ // Handle the standardized ResponseHandler format
+ const data = response.data?.data;
+ const pagination = response.data?.pagination;
+
+ if (Array.isArray(data) && pagination) {
+ return {
+ codes: data,
+ pagination
+ };
+ }
+
+ // Fallback for unexpected formats
+ return {
+ codes: Array.isArray(data) ? data : [],
+ pagination: pagination || { totalRecords: 0, totalPages: 0, currentPage: 1, limit: 10 }
+ };
+}
+
+/**
+ * Get code by ID
+ */
+export async function getHsnSacCodeById(id: string): Promise {
+ const response = await apiClient.get(`/hsn-sac/${id}`);
+ return response.data?.data || response.data;
+}
+
+/**
+ * Create new HSN/SAC code
+ */
+export async function createHsnSacCode(data: CreateHsnSacCodeDTO): Promise {
+ const response = await apiClient.post('/hsn-sac', data);
+ return response.data?.data || response.data;
+}
+
+/**
+ * Update HSN/SAC code
+ */
+export async function updateHsnSacCode(id: string, data: Partial): Promise {
+ const response = await apiClient.patch(`/hsn-sac/${id}`, data);
+ return response.data?.data || response.data;
+}
+
+/**
+ * Delete HSN/SAC code
+ */
+export async function deleteHsnSacCode(id: string): Promise {
+ await apiClient.delete(`/hsn-sac/${id}`);
+}
+
+/**
+ * Toggle active status
+ */
+export async function toggleHsnSacCodeActive(id: string): Promise {
+ const response = await apiClient.patch(`/hsn-sac/${id}/toggle-active`);
+ return response.data?.data || response.data;
+}
diff --git a/src/services/tanflowAuth.ts b/src/services/tanflowAuth.ts
index 0b3c5fc..179e1dc 100644
--- a/src/services/tanflowAuth.ts
+++ b/src/services/tanflowAuth.ts
@@ -24,7 +24,6 @@ export function initiateTanflowLogin(): void {
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);
@@ -43,10 +42,8 @@ export function initiateTanflowLogin(): void {
// 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;
}
@@ -161,29 +158,22 @@ export function tanflowLogout(idToken: string): void {
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);
+
+ // Use the exact whitelisted login callback URI without query params to avoid mismatch errors
+ const redirectUri = `${window.location.origin}/login/callback`;
+
+ // Persist logout flag in sessionStorage before redirecting
+ // This allows AuthContext to detect the return from logout without query params
+ sessionStorage.setItem('__logout_type__', 'tanflow');
+
+ logoutUrl.searchParams.set('post_logout_redirect_uri', redirectUri);
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
@@ -195,7 +185,6 @@ export function tanflowLogout(idToken: string): void {
// 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;
}
diff --git a/src/utils/tokenManager.ts b/src/utils/tokenManager.ts
index 8e175c7..63d4284 100644
--- a/src/utils/tokenManager.ts
+++ b/src/utils/tokenManager.ts
@@ -288,6 +288,25 @@ export class TokenManager {
static isProduction(): boolean {
return isProduction();
}
+
+ /**
+ * Set authentication error state (e.g. "SESSION_SUPERSEDED")
+ * Used to flag specific fatal errors without immediately clearing all state
+ */
+ static setAuthError(error: string | null): void {
+ if (error) {
+ sessionStorage.setItem('__auth_error__', error);
+ } else {
+ sessionStorage.removeItem('__auth_error__');
+ }
+ }
+
+ /**
+ * Get authentication error state
+ */
+ static getAuthError(): string | null {
+ return sessionStorage.getItem('__auth_error__');
+ }
}
/**